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

Escape Hatches: Getting CPU Work Off the GIL

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

Programming / Pythonv1.0.0Updated OCT 21 20267 references2 scripts

Once measurement says "GIL-bound", there are many exits. They differ by an order of magnitude in payoff and in cost. Try them roughly in this order: fix the algorithm, vectorise, compile the hot loop, then parallelise. Parallelism multiplies whatever per-item cost remains, so make that cost small first.

Contents

  1. Decision table
  2. Algorithmic fixes first
  3. Vectorisation: numpy, pandas, polars
  4. numba
  5. Cython
  6. mypyc
  7. Rust with PyO3
  8. Processes
  9. Threads over GIL-releasing code
  10. GPU offload
  11. asyncio for the waiting parts
  12. Free-threaded build and sub-interpreters
  13. Combining hatches without oversubscribing

1. Decision table

Hot path looks likeBest hatchTypical gainCost / riskLoses when
Nested loops, repeated lookups, re-parsingalgorithm / data structure10-1000xthinkingalready optimal
Arithmetic over arrays, columns, embeddingsnumpy / polars / pandas column ops10-100xrewrite into array stylelogic is branchy per element
Numeric loop with branches, custom kernelnumba @njit(nogil=True)10-200xnumba-supported subset only; JIT warm-upstrings, dicts of objects, pandas
Typed loops over C types, existing C libsCython (with nogil:)5-100xbuild step, C compilerheavy Python-object work
Plain typed Python, whole modulesmypyc~1.5-5x (varies)build step; keeps the GILhot path is in C already
String parsing, tokenizing, long-lived libRust + PyO3 (release with allow_threads / detach)10-100xRust skills, build toolingtiny team, short-lived script
Independent pure-Python items ≥ ~1 msprocesses≈ cores (minus IPC)pickling, RAM per worker, start-upitems tiny or arguments huge
Native code already releasing the GILthreads≈ coresoversubscription with native poolsnative lib already parallel
Big batched tensor mathGPU (torch, CuPy)10-100x+transfer cost, memory, hardwaresmall batches, branchy code
Waiting on network/diskasyncio or threads≈ concurrency limitrate limits, backpressurework is CPU
Pure-Python CPU needing shared statefree-threaded 3.14t + threads≈ coresextension support; single-thread penaltyany dependency re-enables the GIL
Pure-Python CPU, stdlib-only, avoid processesInterpreterPoolExecutor (3.14)≈ coresextension support; copy semanticsnumpy/torch in the task

2. Algorithmic fixes first

Profile hot spots with huge ncalls or nested-loop shapes are usually algorithmic.

python
import time

docs = [f"doc-{i}" for i in range(20_000)]
seen_list = docs[:5_000]

t0 = time.perf_counter()
dupes_slow = [d for d in docs if d in seen_list]        # O(n*m): list membership scans
t1 = time.perf_counter()
seen = set(seen_list)
dupes_fast = [d for d in docs if d in seen]             # O(n): hash lookups
t2 = time.perf_counter()
assert dupes_slow == dupes_fast
print(f"list {t1 - t0:.3f}s  set {t2 - t1:.4f}s")

Common wins in AI pipelines:

SmellFix
x in list inside a loopset / dict
re-compiling regexes per documentcompile once at module level
re-tokenizing the same text for counting and chunkingtokenize once, reuse ids
sorting all scores to take the top 10heapq.nlargest(10, ...) or np.argpartition
building strings with += in a loop"".join(parts)
json.dumps → json.loads round trips to copycopy.deepcopy or build the right structure once
per-item model or tokenizer constructionconstruct once, reuse
exact nearest-neighbour search over millions of vectorsan ANN index (FAISS, HNSW)

3. Vectorisation: numpy, pandas, polars

Move the loop into C: one call over the whole batch instead of one Python operation per element. numpy releases the GIL inside most numeric operations, so vectorised code can additionally be threaded.

python
import numpy as np

rng = np.random.default_rng(0)
emb = rng.standard_normal((10_000, 384)).astype(np.float32)
query = rng.standard_normal(384).astype(np.float32)

# Python loop: holds the GIL, ~1000x slower
def top_k_loop(emb, query, k=5):
    scores = []
    for row in emb.tolist():
        dot = sum(a * b for a, b in zip(row, query.tolist()))
        scores.append(dot)
    return sorted(range(len(scores)), key=scores.__getitem__, reverse=True)[:k]

# Vectorised: normalise, one matrix-vector product, partial sort
def top_k_vec(emb, query, k=5):
    emb_n = emb / np.linalg.norm(emb, axis=1, keepdims=True)
    q_n = query / np.linalg.norm(query)
    scores = emb_n @ q_n
    idx = np.argpartition(-scores, k)[:k]
    return idx[np.argsort(-scores[idx])].tolist()

print(top_k_vec(emb, query))

pandas: replace df.apply(f, axis=1) and iterrows with column expressions.

python
import numpy as np
import pandas as pd

df = pd.DataFrame({"tokens": [120, 5000, 800], "latency_ms": [200, 2500, 900]})
# slow: df["bucket"] = df.apply(lambda r: "long" if r.tokens > 1000 else "short", axis=1)
df["bucket"] = np.where(df["tokens"] > 1000, "long", "short")
df["tok_per_s"] = df["tokens"] / (df["latency_ms"] / 1000)

polars runs expressions in Rust on its own thread pool with the GIL released:

python
import polars as pl

df = pl.DataFrame({"text": ["a b c", "d e"], "source": ["web", "pdf"]})
out = (
    df.with_columns(pl.col("text").str.split(" ").list.len().alias("n_words"))
      .group_by("source")
      .agg(pl.col("n_words").sum())
)

Avoid np.vectorize (a Python loop in disguise), object dtypes, and map_elements/apply with Python lambdas in polars: those all run Python per element and hold the GIL.

4. numba

@njit compiles a numeric Python subset to machine code. nogil=True releases the GIL while the compiled function runs, so ordinary threads then scale; parallel=True with prange parallelises inside the function.

python
import numpy as np
from concurrent.futures import ThreadPoolExecutor
from numba import njit, prange


@njit(nogil=True, cache=True)
def minhash_row(tokens: np.ndarray, seeds: np.ndarray) -> np.ndarray:
    out = np.empty(seeds.shape[0], dtype=np.uint64)
    for j in range(seeds.shape[0]):
        best = np.uint64(0xFFFFFFFFFFFFFFFF)
        for t in tokens:
            h = (t * np.uint64(0x9E3779B97F4A7C15) + seeds[j]) & np.uint64(0xFFFFFFFFFFFFFFFF)
            if h < best:
                best = h
        out[j] = best
    return out


@njit(parallel=True, cache=True)
def l2_normalise(x: np.ndarray) -> np.ndarray:
    out = np.empty_like(x)
    for i in prange(x.shape[0]):                       # rows split across numba's threads
        out[i] = x[i] / np.sqrt((x[i] ** 2).sum())
    return out


docs = [np.arange(i, i + 500, dtype=np.uint64) for i in range(64)]
seeds = np.arange(128, dtype=np.uint64)
with ThreadPoolExecutor(8) as ex:                       # threads scale because of nogil=True
    sigs = list(ex.map(lambda d: minhash_row(d, seeds), docs))

Notes: the first call compiles (seconds); cache=True writes the compiled code to __pycache__. Supported: numpy arrays, scalars, tuples, typed containers; not arbitrary Python objects, pandas or most strings. parallel=True uses numba's own threading layer: set NUMBA_NUM_THREADS or numba.set_num_threads(n) and avoid also running it in many processes.

5. Cython

Cython compiles Python-like code with C types; inside with nogil: blocks the GIL is released, so only C-level operations are allowed there.

cython
# cython: language_level=3, boundscheck=False, wraparound=False
# file: fastscore.pyx
from cython.parallel cimport prange

def dot_rows(const float[:, ::1] emb, const float[::1] q, float[::1] out):
    cdef Py_ssize_t i, j, n = emb.shape[0], d = emb.shape[1]
    cdef float acc
    with nogil:                                   # other Python threads run meanwhile
        for i in prange(n, schedule="static"):    # OpenMP: compile with -fopenmp
            acc = 0
            for j in range(d):
                acc = acc + emb[i, j] * q[j]
            out[i] = acc

Build with cythonize -i fastscore.pyx (or setuptools/meson). Use typed memoryviews over numpy arrays, keep Python objects out of nogil blocks, and declare # cython: freethreading_compatible = True (Cython 3.1+) once the module is safe for free-threaded builds.

6. mypyc

mypyc compiles type-annotated Python modules to C extensions. It speeds up attribute access, calls and arithmetic on typed code (often 1.5-5x, sometimes more); it does not release the GIL, so it helps serial speed, not thread scaling.

bash
pip install mypy
mypyc ingest/chunking.py          # builds chunking.<abi>.so next to the source
python -c "import ingest.chunking as c; print(c.__file__)"   # confirm the compiled module is used

Good fit: an existing well-typed pure-Python module (tokenizer helpers, rule engines). Poor fit: code that is mostly calls into numpy/regex already, or heavy dynamic features (monkeypatching, getattr tricks).

7. Rust with PyO3

For string-heavy hot paths (custom tokenization, normalisation, chunking, parsing) Rust gives C speed with memory safety. Release the GIL around pure-Rust work so Python threads run in parallel. The method is py.allow_threads(...) in PyO3 up to 0.25 and py.detach(...) in newer releases.

rust
use pyo3::prelude::*;

#[pyfunction]
fn count_tokens(py: Python<'_>, texts: Vec<String>) -> Vec<usize> {
    // texts were copied out of Python objects above; no Python access inside the closure
    py.allow_threads(|| texts.iter().map(|t| t.split_whitespace().count()).collect())
}

#[pymodule(gil_used = false)]          // declares free-threading support (recent PyO3)
fn fasttok(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(count_tokens, m)?)?;
    Ok(())
}

Build with maturin develop --release. Batch the API (accept lists, return arrays) so the Python ↔ Rust crossing cost is paid once per batch, and use rayon inside for parallelism instead of Python threads when the batch is large (then do not also parallelise around it).

8. Processes

The universal hatch for pure-Python CPU work: each process has its own GIL. Costs: start-up and imports per worker, pickling every argument and result, RAM per worker. Design details (start methods, chunksize, initializers, shared memory, failures) live in the multiprocessing pool designer.

python
from concurrent.futures import ProcessPoolExecutor
import re

WS = re.compile(r"\s+")


def clean_shard(lines: list[str]) -> list[str]:          # module-level: picklable
    return [WS.sub(" ", ln).strip().lower() for ln in lines]


def shards(lines, size):
    for i in range(0, len(lines), size):
        yield lines[i:i + size]


if __name__ == "__main__":                               # required for spawn/forkserver
    corpus = [f"  Line {i}\twith   EXTRA   spaces " for i in range(200_000)]
    with ProcessPoolExecutor(max_workers=4) as ex:
        cleaned = [ln for part in ex.map(clean_shard, shards(corpus, 10_000)) for ln in part]
    print(len(cleaned), cleaned[0])

Rules of thumb: ship shards (thousands of items per task), not single items; send paths/ids instead of big objects; load models once per worker with initializer=; cap native threads per worker (troubleshooting-oversubscription-and-contention.md).

9. Threads over GIL-releasing code

When the hot call releases the GIL (hashing, compression, numpy on large arrays, image decode, onnxruntime, torch inference), threads are cheaper than processes: no pickling, shared model weights, one copy in memory.

python
import hashlib
import zlib
from concurrent.futures import ThreadPoolExecutor

blobs = [bytes([i % 251]) * 2_000_000 for i in range(16)]   # 2 MB "documents"


def fingerprint_and_pack(blob: bytes) -> tuple[str, int]:
    digest = hashlib.sha256(blob).hexdigest()       # GIL released for big buffers
    packed = zlib.compress(blob, 6)                 # GIL released
    return digest, len(packed)


with ThreadPoolExecutor(max_workers=8) as ex:
    results = list(ex.map(fingerprint_and_pack, blobs))
print(len(results), results[0][1])

Make sure the native library is not also spawning a full-size pool per call (BLAS, torch intra-op threads): pick one level of parallelism.

10. GPU offload

When the work is batched tensor math (embedding, reranking with a cross-encoder, similarity over millions of vectors), the GPU beats any CPU parallelism. The Python side must keep it fed: batch on the CPU, move once, compute, move back once.

python
import torch

device = "cuda" if torch.cuda.is_available() else "cpu"
emb = torch.randn(100_000, 768, device=device, dtype=torch.float16 if device == "cuda" else torch.float32)
queries = torch.randn(64, 768, device=device, dtype=emb.dtype)
with torch.inference_mode():
    emb = torch.nn.functional.normalize(emb, dim=1)
    q = torch.nn.functional.normalize(queries, dim=1)
    top = (q @ emb.T).topk(10, dim=1)              # one kernel for 64 x 100k similarities
print(top.indices.shape)

Python-side overhead (building tensors from lists, per-item .item() calls, tokenization on the main thread) often becomes the new bottleneck: profile the CPU side again after moving to GPU.

11. asyncio for the waiting parts

If the profile is mostly io-wait, no CPU hatch helps; overlap the waiting. asyncio handles thousands of concurrent requests on one thread; structure, rate limits and timeouts are covered in the asyncio pattern advisor.

python
import asyncio


async def call_llm(prompt: str) -> str:
    await asyncio.sleep(0.1)                         # stands in for an HTTP call
    return prompt.upper()


async def main(prompts: list[str]) -> list[str]:
    sem = asyncio.Semaphore(16)                      # concurrency limit

    async def one(p: str) -> str:
        async with sem:
            return await call_llm(p)

    return await asyncio.gather(*(one(p) for p in prompts))


print(len(asyncio.run(main([f"q{i}" for i in range(100)]))))   # ~0.7 s, not 10 s

Mixed pipelines: asyncio for the network stages, a process pool (via loop.run_in_executor) for CPU stages.

12. Free-threaded build and sub-interpreters

For pure-Python CPU work with shared state, the free-threaded 3.14t build lets plain threads run in parallel, and InterpreterPoolExecutor gives one GIL per worker in one process. Both have compatibility limits; see free-threading-and-subinterpreters.md.

13. Combining hatches without oversubscribing

CombinationOK?Rule
processes × numpy BLAS threadscarefulOMP_NUM_THREADS=1 per worker, or few workers × few threads
threads × numba parallel=Truecarefuleither outer threads or inner prange, not both at full size
processes × HF tokenizerscarefulTOKENIZERS_PARALLELISM=false in workers
asyncio + process poolgoodone pool created at startup, run_in_executor
threads over numba nogil=True (no parallel)goodthreads = cores
vectorised numpy + GPUgoodkeep the transfer batched

Total runnable threads should not exceed usable cores; details and env vars in troubleshooting-oversubscription-and-contention.md.

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.