Free-Threading Performance Trade-offs

The free-threaded build of CPython removes the GIL to unlock multi-core parallelism — but parallelism is not free. The GIL was doing a great deal of implicit, zero-cost synchronization, and replacing it with fine-grained mechanisms imposes a measurable tax on single-threaded execution and on memory footprint, in exchange for multi-core scaling that the default build can never achieve. As of Python 3.14 (3.14.5, May 2026) the single-threaded overhead is “roughly 5-10%, depending on the platform and C compiler used” (What’s New in 3.14) — down dramatically from the 3.13 build, which the 3.13 docs described only as “a substantial single-threaded performance hit” (What’s New in 3.13). This note quantifies the trade-off, explains why the overhead exists, and identifies when free-threading actually pays off. It is the companion to Free-Threaded CPython, which covers the mechanism.

Mental Model

The trade-off is a straight exchange: you pay a fixed per-operation tax on every thread (so single-threaded code is slower and uses more memory), and in return you remove the serialization ceiling that the GIL imposed on multi-threaded CPU-bound code. Whether the trade is worth it depends entirely on how many cores you can actually keep busy with Python bytecode.

flowchart LR
    subgraph GIL["Default build (GIL)"]
      G1["Thread 1"] --> GL["one global lock<br/>(only one runs at a time)"]
      G2["Thread 2"] --> GL
      G3["Thread 3"] --> GL
      GL --> GC1["1 core busy<br/>cheap refcount, small objects"]
    end
    subgraph FT["Free-threaded build"]
      F1["Thread 1"] --> FC["N cores busy in parallel"]
      F2["Thread 2"] --> FC
      F3["Thread 3"] --> FC
      FC --> FCOST["per-op tax:<br/>biased-refcount bookkeeping,<br/>per-object locks, specialize-once,<br/>bigger headers, deferred frees"]
    end

Figure: the GIL build caps CPU-bound multi-threaded work at one core but keeps per-operation costs minimal; the free-threaded build lifts the cap but adds a fixed tax to every operation. The insight: free-threading only wins when the parallel speedup (right) exceeds the per-operation overhead (the FCOST box) multiplied across all the work — i.e. when you have several cores’ worth of CPU-bound Python to run.

The Single-Threaded Overhead — A Moving Target

There are three distinct numbers in circulation and conflating them is the most common error. They form a chronology, and each must be dated.

1. PEP 703’s design-time projection (2023). Before the build shipped, PEP 703 measured a prototype with pyperformance 1.0.6 and reported single-threaded overhead of 6% on Intel Skylake and 5% on AMD Zen 3 (multi-threaded execution overhead was 8% / 7%) (PEP 703 performance section). This was a projection from a prototype, not the figure for any shipped release — and it is often misquoted as “the” free-threading overhead.

2. Python 3.13’s shipped reality (Oct 2024). The first experimental free-threaded release was substantially slower than the prototype suggested, because the interpreter optimizations had to be turned off or worked around to make them GIL-safe (most importantly, the specializing adaptive interpreter — see below). The 3.13 docs are deliberately vague, saying only to “expect… a substantial single-threaded performance hit” with no number.

Uncertain (dated 2026-06-01)

Verify: the often-cited figure that the 3.13 free-threaded build carried roughly ~40% single-threaded overhead. The 3.13 official docs do not quantify it (“substantial single-threaded performance hit”); the ~40% number appears only in secondary write-ups (Quansight Labs, community benchmarks). The primary Quansight recap (labs.quansight.org/blog/free-threaded-one-year-recap) was HTTP 429 at original research and returned HTTP 403 when re-attempted 2026-06-01, so it remains unconfirmed against a primary source. No local python3.14t build is available to benchmark either. To resolve, run the exact pyperformance comparison that produces this single-threaded figure: build both interpreters at the v3.13.x tag (./configure --disable-gil for the free-threaded one) and run pyperformance run -o gil.json vs pyperformance run -o nogil.json with PYTHON_GIL=0 on the free-threaded build, then pyperformance compare gil.json nogil.json and read the geometric-mean row — that geomean is the number “~40%” is claiming. Equivalently, consult the faster-cpython benchmarking dashboard (github.com/faster-cpython/benchmarking-public) for the 3.13 free-threaded-vs-default run. The direction (3.13 much worse than 3.14) is well established; the precise 40% is not yet primary-verified. #uncertain

3. Python 3.14’s official figure (Oct 2025 / 3.14.5 May 2026). This is the headline number to quote today. What’s New states the implementation of PEP 703 “has been finished… and the specializing adaptive interpreter (PEP 659) is now enabled in free-threaded mode,” and that “the performance penalty on single-threaded code in free-threaded mode is now roughly 5-10%, depending on the platform and C compiler used.” (Verified verbatim in Doc/whatsnew/3.14.rst of the v3.14.5 source tree.) The free-threading HOWTO gives the concrete platform breakdown that instantiates that range: about 1% on macOS aarch64 (Apple Silicon) and about 8% on x86-64 Linux. The variance is real and comes from how well each compiler and architecture handles the extra atomic operations and the larger object headers.

So the correct one-sentence summary, dated: as of CPython 3.14, single-threaded code in the free-threaded build runs roughly 5–10% slower than the GIL build (≈1% on Apple Silicon, ≈8% on x86-64 Linux), a large improvement over the 3.13 experimental build. PEP 779’s acceptance criteria capped this at ~15%, which 3.14 comfortably meets.

Why the Overhead Exists

Per PEP 703’s own analysis, “the largest contribution to execution overhead is biased reference counting followed by per-object locking.” Breaking it down:

Biased reference-counting bookkeeping. In the default build, Py_INCREF/Py_DECREF are a plain non-atomic ++/-- on one machine word — essentially free. The free-threaded build’s biased reference counting splits the count into a thread-local “owner” count and a shared atomic count, and every refcount operation must first branch on “am I the owning thread?” before deciding which field to touch and whether an atomic is needed. Refcounts are touched constantly (every name load, every function call argument), so even a few extra instructions per operation, multiplied across a whole program, is the dominant cost. Deferred Reference Counting and Immortal Objects exist precisely to remove refcount traffic on the hottest objects (functions, modules, types, and the None/True/False singletons) and claw back some of this.

Per-object locking. Operations that mutate shared structure — appending to a list, inserting into a dict — must take the object’s internal lock (Per-Object Locking in Free-Threading) where the GIL build took nothing. Even an uncontended lock acquire/release is a few atomic instructions, and contention is worse.

Loss of specialization opportunity (specialize-once). The specializing adaptive interpreter rewrites hot bytecodes into type-specialized fast paths. In a GIL-less world, two threads writing the same inline cache could corrupt it. PEP 703 noted the consequence: “In multi-threaded programs running without the GIL, each bytecode is only specialized once. This prevents a thread from reading a partially written inline cache.” In 3.13, specialization in the free-threaded build was effectively disabled, which was the single biggest reason 3.13’s overhead was so much worse than the prototype. In 3.14 it is enabled, but with this lock-free specialize-once constraint: each instruction is specialized exactly once, guarded by an atomic compare-and-exchange on the opcode byte (verified in Python/specialize.c, v3.14.5, which uses _Py_atomic_compare_exchange_uint8 on instr->op.code and FT_ATOMIC_* macros throughout). The practical effect is that the free-threaded build gets most of the specialization benefit but slightly less than the GIL build, because it cannot re-specialize an instruction whose observed types shift over time. Re-enabling specialization is the headline reason the overhead fell from “substantial” to 5-10% between 3.13 and 3.14.

To make the specialize-once limitation concrete, consider a call site that is polymorphic over time — a loop body that for its first ten thousand iterations always adds two ints, then switches to adding two floats:

def hot(a, b):
    return a + b          # the BINARY_OP bytecode here is the specialization target
 
for i in range(10_000):
    hot(1, 2)             # observed types: (int, int)
for i in range(10_000):
    hot(1.0, 2.0)         # observed types: (float, float)

In the GIL build, the adaptive interpreter specializes BINARY_OP to BINARY_OP_ADD_INT during the first loop. When the second loop starts feeding it floats, the int-specialized fast path’s type guard fails repeatedly; the interpreter de-optimizes the instruction back to the generic adaptive form and then re-specializes it to BINARY_OP_ADD_FLOAT. The hot path stays optimal across the type shift because the GIL guarantees no other thread can observe a half-rewritten opcode mid-de-optimization.

In the free-threaded build, that de-optimize-and-rewrite dance is unsafe: a second thread could be executing the same instruction at the instant the cache is being torn down and rebuilt. So 3.14 uses the lock-free specialize-once rule — the very first specialization wins, committed via a single atomic compare-and-exchange on the opcode byte (the _Py_atomic_compare_exchange_uint8(&instr->op.code, ...) in Python/specialize.c, v3.14.5). Once BINARY_OP becomes BINARY_OP_ADD_INT, it stays int-specialized. In the second loop the float operands miss the int guard on every iteration and fall through the (slower) deopt-to-generic path, but the instruction is never re-specialized to floats. For monomorphic code — the overwhelmingly common case — specialize-once is just as good as the GIL build. For genuinely polymorphic-over-time hot code, the free-threaded build leaves performance on the table that the GIL build would recover. This is the residual specialization gap baked into the 5-10% headline number, and it is the price of making inline-cache writes race-free without a lock.

Memory Overhead

Free-threading also costs memory. The free-threading HOWTO enumerates the causes:

  • Larger object headers. Non-GC objects have a larger PyObject structure in the free-threaded build — the HOWTO’s concrete example is that None occupies 32 bytes versus 16 bytes in the default build. The extra words hold the per-object lock state and the biased-refcount fields. Across millions of small objects this adds up.
  • Immortal interned strings. In the free-threaded build, strings interned via sys.intern() become immortal and survive until interpreter shutdown, rather than being reclaimable as in the GIL build — trading memory for the elimination of refcount contention on interned strings. (This connects to the broader Immortal Objects story.)
  • Deferred reclamation (QSBR). Memory is freed later because of Quiescent State-Based Reclamation: a freed object’s memory cannot be returned until no thread could still be reading it. You can force reclamation with gc.collect(). This makes steady-state RSS higher and “spikier.”
  • mimalloc heaps. The build uses mimalloc instead of pymalloc for object allocation, with separate per-thread heaps; this avoids allocation lock contention but can hold more memory resident. MIMALLOC_PURGE_DELAY=0 returns memory to the OS sooner at some allocator-performance cost.
  • Per-thread / queued refcount state. Heap types, code objects, and module __dict__s use per-thread reference counting that merges at safe points, so objects can stay alive slightly longer than under the GIL.

PEP 779’s acceptance criteria targeted roughly a 20% memory increase (geometric mean across pyperformance), so this is a known, budgeted cost rather than an accident.

Uncertain (dated 2026-06-01)

Verify: that the ~20% memory-increase figure describes the 3.14 shipped build and not just the PEP 779 target. PEP 779 states 20% as a Phase-II requirement/target (PEP 779); whether 3.14.5 actually lands at ~20% (vs better or worse) is not stated in the primary 3.14 docs consulted, and the Quansight recap that reports the shipped figure is still fetch-blocked (403 as of 2026-06-01). No local python3.14t build is available to measure. To resolve, run the exact pyperformance memory comparison: pyperformance run --track-memory -o ft.json on the v3.14.5 free-threaded build (PYTHON_GIL=0) and the same on the default v3.14.5 build, then pyperformance compare default.json ft.json and read the geometric-mean of the memory (max RSS) column — that geomean against the default build is exactly the number PEP 779 budgets at ~20%. The PEP target is verified; the shipped 3.14.5 actual is not. #uncertain

The Multi-Core Scaling Win — When It Pays Off

The entire point is the upside: CPU-bound Python that genuinely uses multiple threads can now scale across cores instead of being pinned to one by the GIL. Concretely, on a CPU-bound threaded benchmark, the 3.14 free-threaded build reached about a 3.09× speedup with 4 threads, versus only about 2.2× with 4 threads in the 3.13 free-threaded build — both far above the GIL build’s hard ceiling of ~1× for pure-Python CPU work no matter how many threads you add.

Uncertain (dated 2026-06-01)

Verify: the specific scaling numbers (3.09× at 4 threads in 3.14; 2.2× at 4 threads in 3.13). These come from a Quansight Labs benchmarking summary surfaced via web search; the primary post (labs.quansight.org) returned HTTP 429 originally and HTTP 403 on re-attempt 2026-06-01, so it could not be fetched. The qualitative claim — near-linear-ish scaling on CPU-bound threaded work, improved 3.13→3.14 — is well supported; the exact multipliers are workload-dependent and unverified against a primary source. To resolve, run the threaded-scaling benchmark these numbers come from: pyperformance’s free-threading suite includes ftime/bm_* threaded cases (or use a fixed CPU-bound workload such as the parallel fib/raytrace benchmark), measured at 1 vs 4 threads under PYTHON_GIL=0 on a v3.14.5 --disable-gil build and again on a v3.13.x free-threaded build; the per-build (4-thread time / 1-thread time) ratio is the speedup these multipliers report. No local python3.14t is available to run this. Treat the multipliers as approximate. #uncertain

The decision rule:

  • Free-threading pays off when you have CPU-bound work, several cores available, and substantial shared in-memory state that would be expensive to copy across processes. Example: parallel processing over a large shared data structure, ML preprocessing, or a server doing CPU-heavy work on shared caches.
  • Free-threading does not pay off for single-threaded scripts (you just eat the 5-10% tax for nothing), for I/O-bound workloads (the GIL is already released during I/O, so threads or asyncio on the default build scale fine), or for embarrassingly-parallel work with little shared state (where multiprocessing avoids the per-operation tax entirely).

The break-even is roughly: if your parallel section can keep N cores busy with Python bytecode, you need N × (the parallel efficiency) to exceed the ~1.05–1.10× single-threaded slowdown. With even 2 well-utilized cores that is an easy win; with 1 core it is always a loss.

Failure Modes and Common Misunderstandings

  • Quoting PEP 703’s 5-6% as “the current overhead.” That was a 2023 prototype projection. The current (3.14) figure is “roughly 5-10%” per platform — coincidentally similar in magnitude, but a different measurement of a different, finished implementation.
  • Expecting linear scaling. Per-object lock contention, QSBR overhead, and any GIL-needing extension (which silently re-enables the GIL — see Free-Threaded CPython) all cap real-world scaling well below N×.
  • Ignoring memory. A service that was RSS-bound under the GIL may regress on the free-threaded build purely from larger headers, immortal interned strings, and deferred frees — independent of any speed change.
  • Benchmarking the wrong thing. Single-threaded microbenchmarks will show only the tax, never the win. The win only appears in genuinely multi-threaded CPU-bound workloads.

See Also