Python 3.12 vs 3.14 vs 3.14t: GIL and free-threading benchmark
Generated 2026-09-12T12:41:05 on Apple M5 (10 cores),
macOS-26.6.2-arm64-arm-64bit. 160 measurements, median of 10 repeats.
3 columns, one interpreter.3.14 (GIL on), 3.14t (free-threaded) and 3.14t (PYTHON_GIL=0 forced) are the same binary run with different GIL settings - not different builds. No stock (GIL-enabled) build of that version was measured here, so a gap between any of them and 3.12 mixes the version change with the free-threaded build's own overhead.
Key findings
CPU-bound cpu_primes at 8 threads vs 1 - 3.12: 0.9x, 3.14 (GIL on): 0.9x, 3.14t (free-threaded): 3.9x.
CPU-bound cpu_float at 8 threads vs 1 - 3.12: 0.9x, 3.14 (GIL on): 1.0x, 3.14t (free-threaded): 4.1x.
Single-threaded overhead of the free-threaded build (1 worker, median over CPU/contended workloads): +12% vs 3.12, +0% vs the same binary with the GIL on.
I/O-bound io_sleep is unaffected: all builds overlap waits equally (8-thread times within 1% of each other).
Contended contended_list_append at 8 threads: free-threaded 0.284 s vs 3.12 0.108 s (slower by 164%).
Contended contended_dict_update at 8 threads: free-threaded 0.847 s vs 3.12 0.356 s (slower by 138%).
Threads on 3.14t vs processes on 3.12 for the same prime-counting work at 8 workers: 0.054 s vs 0.155 s - multiprocessing pays start-up and pickling costs.
Measurement noise: 10 timed repeats per cell, median coefficient of variation 2.1%; 19 of 160 cells exceed 10% (marked ~ in the tables).
Library 'duckdb' does NOT support free-threading: importing it re-enabled the GIL (_duckdb), so its '3.14t (free-threaded)' results are effectively GIL-on.
Library numpy_small_ops at 8 threads vs 1 - 3.12: 1.0x, 3.14 (GIL on): 1.0x, 3.14t (free-threaded): 4.2x.
Library numpy_matmul at 8 threads vs 1 - 3.12: 1.8x, 3.14 (GIL on): 1.8x, 3.14t (free-threaded): 1.7x.
Library pandas_groupby at 8 threads vs 1 - 3.12: 4.7x, 3.14 (GIL on): 4.5x, 3.14t (free-threaded): 4.8x.
Cells: speed-up from 1 to 8 workers, and wall time at 8 workers. ⚠ = the free-threaded run actually had the GIL re-enabled by an extension module (hover, focus or tap any symbol for its meaning).
Bare Python workloads (stdlib only)
CPU-bound
Pure-bytecode number crunching split across threads. This is the case the GIL serialises: expect flat lines on GIL builds and genuine parallel speed-up on the free-threaded build.
cpu_primes
Trial-division prime counting below 300k, range split across threads. Pure bytecode: integer arithmetic and loops, no I/O.
3.123.14 (GIL on)3.14t (free-threaded)
configuration
1 thr
2 thr
4 thr
8 thr
speed-up 1→8
3.12
0.199 s ±0.39 ms
0.199 s ±1.31 ms
0.200 s ±5.93 ms
0.220 s ±0.027 s~
0.9x
3.14 (GIL on)3.14 GIL
0.252 s ±0.013 s
0.258 s ±3.75 ms
0.258 s ±0.012 s
0.276 s ±0.030 s~
0.9x
3.14t (free-threaded)3.14t
0.209 s ±0.60 ms
0.133 s ±0.59 ms
0.075 s ±0.95 ms
0.054 s ±3.13 ms
3.9x
median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).
cpu_float
3M iterations of sin*cos accumulation, range split across threads. Float-heavy bytecode with C-level math calls.
3.123.14 (GIL on)3.14t (free-threaded)
configuration
1 thr
2 thr
4 thr
8 thr
speed-up 1→8
3.12
0.258 s ±0.018 s
0.259 s ±0.013 s
0.261 s ±0.014 s
0.276 s ±0.027 s
0.9x
3.14 (GIL on)3.14 GIL
0.307 s ±0.043 s~
0.308 s ±0.26 ms
0.308 s ±0.95 ms
0.310 s ±0.30 ms
1.0x
3.14t (free-threaded)3.14t
0.309 s ±1.39 ms
0.155 s ±6.17 ms
0.084 s ±5.87 ms
0.075 s ±8.71 ms~
4.1x
median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).
I/O-bound
Threads spend their time waiting. The GIL is released while blocked, so all builds overlap the waits equally - free-threading was never needed here.
io_sleep
Each thread performs 20 x 10ms sleeps (simulated network/disk waits), so 8 threads do 8x the waiting of 1 - unlike every other workload here, the work is fixed per thread, not in total. The GIL is released while sleeping, so all builds should overlap the waits: a flat line (1.0x) is the good outcome here.
3.123.14 (GIL on)3.14t (free-threaded)
configuration
1 thr
2 thr
4 thr
8 thr
speed-up 1→8
3.12
0.240 s ±5.76 ms
0.238 s ±2.78 ms
0.241 s ±2.86 ms
0.241 s ±2.95 ms
1.0x
3.14 (GIL on)3.14 GIL
0.240 s ±3.78 ms
0.240 s ±3.46 ms
0.242 s ±2.64 ms
0.244 s ±1.77 ms
1.0x
3.14t (free-threaded)3.14t
0.240 s ±6.08 ms
0.239 s ±4.09 ms
0.241 s ±2.33 ms
0.242 s ±3.28 ms
1.0x
median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).
Contended shared state
All threads hammer one shared object. Without the GIL, every operation still takes a lock: the object's own critical section, plus - in contended_dict_update - an explicit threading.Lock that every build pays for. This is the cost of fine-grained locking.
contended_list_append
4M appends to one shared list, split across threads. On free-threaded builds each append takes the list's per-object critical section, so more threads means more lock hand-offs.
3.123.14 (GIL on)3.14t (free-threaded)
configuration
1 thr
2 thr
4 thr
8 thr
speed-up 1→8
3.12
0.106 s ±6.30 ms
0.105 s ±0.012 s~
0.108 s ±0.015 s~
0.108 s ±3.95 ms
1.0x
3.14 (GIL on)3.14 GIL
0.192 s ±7.04 ms
0.196 s ±2.89 ms
0.211 s ±5.88 ms
0.230 s ±5.71 ms
0.8x
3.14t (free-threaded)3.14t
0.192 s ±5.32 ms
0.248 s ±0.013 s
0.313 s ±8.81 ms
0.284 s ±9.11 ms
0.7x
median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).
contended_dict_update
2M dict increments guarded by one threading.Lock, split across threads. Measures lock hand-off cost when the GIL is not serialising threads.
3.123.14 (GIL on)3.14t (free-threaded)
configuration
1 thr
2 thr
4 thr
8 thr
speed-up 1→8
3.12
0.359 s ±0.025 s
0.380 s ±8.31 ms
0.308 s ±0.025 s
0.356 s ±0.040 s~
1.0x
3.14 (GIL on)3.14 GIL
0.290 s ±3.51 ms
0.292 s ±5.28 ms
0.293 s ±6.02 ms
0.294 s ±1.51 ms
1.0x
3.14t (free-threaded)3.14t
0.292 s ±4.85 ms
0.608 s ±0.039 s
0.753 s ±0.108 s~
0.847 s ±0.139 s~
0.3x
median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).
Multiprocessing reference
The classic GIL workaround: processes instead of threads. Includes process start-up and pickling overhead, and the same work as cpu_primes for direct comparison.
mp_primes
Same prime counting as cpu_primes, but using a multiprocessing.Pool (spawn). The classic GIL workaround; includes process start-up cost.
3.123.14 (GIL on)3.14t (free-threaded)
configuration
1 thr
2 thr
4 thr
8 thr
speed-up 1→8
3.12
0.314 s ±0.017 s
0.237 s ±7.92 ms
0.174 s ±0.011 s
0.155 s ±0.016 s~
2.0x
3.14 (GIL on)3.14 GIL
0.280 s ±3.47 ms
0.203 s ±1.86 ms
0.148 s ±2.13 ms
0.146 s ±0.017 s~
1.9x
3.14t (free-threaded)3.14t
0.281 s ±7.03 ms
0.207 s ±1.40 ms
0.155 s ±3.76 ms
0.142 s ±7.85 ms
2.0x
median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).
Library workloads (optional tier)
Third-party extension modules. Some release the GIL in C already (so they scale on every build), some hold it, and some re-enable it for the whole process on import.
Free-threading support check (check_gil_support.py, probed on 3.14t)
package
version
wheel ABI
status
detail
duckdb
1.5.5
cp314t
✗Re-enables GILREENABLES_GIL
'_duckdb' has not declared Py_MOD_GIL_NOT_USED, so CPython re-enabled the GIL for the whole process (override with PYTHON_GIL=0 at your own risk)
fastapi
0.141.1
pure (none-any)
✓SupportedSUPPORTED
1 extension module(s) loaded, GIL stayed off
numpy
2.5.3
cp314t
✓SupportedSUPPORTED
2 extension module(s) loaded, GIL stayed off
pandas
3.0.5
cp314t
✓SupportedSUPPORTED
55 extension module(s) loaded, GIL stayed off
sklearn
1.9.1
cp314t
✓SupportedSUPPORTED
145 extension module(s) loaded, GIL stayed off
uvicorn
0.52.4
pure (none-any)
✓Pure PythonPURE_PYTHON
no compiled extension modules were loaded
numpy_small_ops
8 tasks x 20k tiny (256-element) array ops. Python dispatch overhead dominates and holds the GIL, so GIL builds should not scale.
3.123.14 (GIL on)3.14t (free-threaded)
configuration
1 thr
2 thr
4 thr
8 thr
speed-up 1→8
3.12
0.274 s ±2.57 ms
0.279 s ±2.79 ms
0.279 s ±2.24 ms
0.279 s ±3.96 ms
1.0x
3.14 (GIL on)3.14 GIL
0.284 s ±2.09 ms
0.287 s ±3.32 ms
0.291 s ±1.93 ms
0.293 s ±8.81 ms
1.0x
3.14t (free-threaded)3.14t
0.284 s ±4.34 ms
0.203 s ±1.82 ms
0.088 s ±0.98 ms
0.067 s ±1.21 ms
4.2x
median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).
numpy_matmul
8 tasks x 8 matmuls of 512x512 float64 (BLAS threads pinned to 1). numpy releases the GIL inside BLAS, so all builds should scale.
3.123.14 (GIL on)3.14t (free-threaded)
configuration
1 thr
2 thr
4 thr
8 thr
speed-up 1→8
3.12
0.098 s ±3.70 ms
0.077 s ±0.80 ms
0.069 s ±4.19 ms
0.053 s ±2.13 ms
1.8x
3.14 (GIL on)3.14 GIL
0.097 s ±0.28 ms
0.078 s ±0.69 ms
0.072 s ±0.79 ms
0.055 s ±1.20 ms
1.8x
3.14t (free-threaded)3.14t
0.097 s ±1.16 ms
0.077 s ±1.14 ms
0.070 s ±1.65 ms
0.057 s ±2.09 ms
1.7x
median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).
pandas_groupby
8 tasks: build a 1M-row DataFrame and groupby-sum. Most of the wall time is spent in C/Cython with the GIL released, so this scales on every build - the GIL is not the bottleneck here.
3.123.14 (GIL on)3.14t (free-threaded)
configuration
1 thr
2 thr
4 thr
8 thr
speed-up 1→8
3.12
0.143 s ±1.35 ms
0.076 s ±2.17 ms
0.045 s ±2.13 ms
0.030 s ±2.18 ms
4.7x
3.14 (GIL on)3.14 GIL
0.143 s ±2.83 ms
0.078 s ±1.42 ms
0.044 s ±2.28 ms
0.032 s ±1.56 ms
4.5x
3.14t (free-threaded)3.14t
0.144 s ±3.48 ms
0.077 s ±5.77 ms
0.043 s ±3.55 ms
0.030 s ±0.012 s~
4.8x
median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).
duckdb_query
8 tasks: SELECT sum(i*i) FROM range(5M) on a per-task connection with SET threads=1. DuckDB releases the GIL during execution. NOTE: importing duckdb re-enables the GIL on free-threaded builds unless PYTHON_GIL=0 is forced.
GIL re-enabled. On the free-threaded build an extension module imported by this workload re-enabled the GIL, so the "3.14t (free-threaded)" line ran with the GIL on. See the support table above.
median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).
sklearn_fit
8 tasks: make_classification(4000x20) + RandomForestClassifier(20 trees, n_jobs=1).fit + predict. Cython tree building releases the GIL in hot loops.
3.123.14 (GIL on)3.14t (free-threaded)
configuration
1 thr
2 thr
4 thr
8 thr
speed-up 1→8
3.12
1.291 s ±5.58 ms
0.668 s ±4.64 ms
0.360 s ±3.42 ms
0.238 s ±9.35 ms
5.4x
3.14 (GIL on)3.14 GIL
1.324 s ±0.019 s
0.692 s ±0.010 s
0.365 s ±4.30 ms
0.252 s ±0.010 s
5.3x
3.14t (free-threaded)3.14t
1.328 s ±0.352 s~
0.668 s ±5.99 ms
0.365 s ±0.033 s
0.214 s ±0.012 s
6.2x
median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).
fastapi_sync_cpu
32 requests to a sync def endpoint (prime count to 30k) from N concurrent clients; uvicorn runs in a subprocess on the same interpreter. def endpoints run on a thread pool, so free-threading can parallelise them.
3.123.14 (GIL on)3.14t (free-threaded)
configuration
1 thr
2 thr
4 thr
8 thr
speed-up 1→8
3.12
0.279 s ±1.43 ms
0.272 s ±1.86 ms
0.272 s ±0.65 ms
0.271 s ±1.35 ms
1.0x
3.14 (GIL on)3.14 GIL
0.307 s ±0.99 ms
0.304 s ±1.41 ms
0.302 s ±1.22 ms
0.306 s ±7.57 ms
1.0x
3.14t (free-threaded)3.14t
0.304 s ±0.73 ms
0.154 s ±4.61 ms
0.083 s ±2.72 ms
0.078 s ±0.014 s~
3.9x
median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).
fastapi_async_json
400 requests to a trivial async endpoint from N concurrent clients. Event-loop bound; measures per-request overhead of each build.
3.123.14 (GIL on)3.14t (free-threaded)
configuration
1 thr
2 thr
4 thr
8 thr
speed-up 1→8
3.12
0.080 s ±0.91 ms
0.055 s ±0.80 ms
0.046 s ±0.27 ms
0.043 s ±0.36 ms
1.9x
3.14 (GIL on)3.14 GIL
0.088 s ±3.73 ms
0.060 s ±1.50 ms
0.055 s ±0.66 ms
0.054 s ±0.012 s~
1.6x
3.14t (free-threaded)3.14t
0.099 s ±0.017 s~
0.057 s ±1.25 ms
0.052 s ±0.95 ms
0.050 s ±3.16 ms
2.0x
median ± sample standard deviation over the timed repeats; hover, focus or tap ± for min / max; ~ marks a noisy cell (cv > 10%).
Implementation differences: what actually changed inside CPython
All statements below about this machine are measured (see the facts table); the rest describes CPython's
design as documented in PEP 703 (free-threaded CPython), PEP 779 (free-threading officially supported in 3.14),
PEP 683 (immortal objects) and the CPython free-threading HOWTOs.
1. Timeline
3.12 - one GIL per interpreter (PEP 684 made it per-subinterpreter for the C-API). Threads never
run bytecode concurrently.
3.13 - PEP 703 lands as an experimental separate build (--disable-gil, binary
python3.13t). Specialisation was switched off in that build, costing ~40% single-thread performance.
3.14 - PEP 779: the free-threaded build is officially supported (no longer experimental). The
specialising interpreter is back on, single-thread overhead is quoted by the core team at roughly 5-10%, and the build is what
this report calls 3.14t. The default (non-t) 3.14 build is unchanged and still has the GIL.
2. Reference counting: biased, deferred, immortal
With the GIL, Py_INCREF is a plain non-atomic increment. Without it, every increment from every thread would
have to be atomic - a large slowdown. Free-threaded CPython uses three tricks:
Biased reference counting - each object records its owning thread (ob_tid) and keeps
two counters: ob_ref_local, touched only by the owner without atomics, and ob_ref_shared,
an atomic counter used by every other thread. The true count is their sum; they are merged when the object is freed or its
ownership changes.
Immortal objects (PEP 683) - None, True, small ints, interned strings, static
types etc. have a saturated count and are never inc/dec'd, so hot shared constants create no cache-line traffic.
Deferred reference counting - functions, code objects, modules and top-level classes are referenced from
the interpreter's evaluation stack without counting; the garbage collector accounts for those references instead. 3.14 goes
further with tagged stack references in the evaluation loop.
The price is a bigger object header: ob_tid, a one-byte ob_mutex, GC bits and the two counters.
Measured here:sys.getsizeof(object()) is 16 bytes on 3.12 and 32 on 3.14t; a small
int and a short str each grow by 16 bytes.
3. Memory allocator and garbage collector
mimalloc replaces pymalloc. It has per-thread heaps, so allocation needs no global lock, and
its heap structures let the collector enumerate objects.
Because of that, the free-threaded build drops the 16-byte PyGC_Head doubly-linked-list pre-header that
every GC-tracked object carries on 3.12. Measured here:[], {} and class
instances come out the same size on both builds - for them the bigger object header is exactly offset by the
missing GC header. It does not cancel everywhere: in the same table a 2-tuple and a lambda are still 8 bytes
larger on 3.14t.
The cycle collector is stop-the-world: it pauses every thread at a safe point (the "eval breaker"
check), runs a single-generation collection, then resumes them. The default (GIL) 3.14 build keeps its generational
collector. Note that gc.get_threshold() reads (2000, 10, 10) on every 3.14 column versus
(700, 10, 10) on 3.12. That is a 3.12→3.14 change rather than a free-threading one - but this run cannot
be the evidence for it, because it never measured a stock 3.14: all three 3.14 columns are the one free-threaded binary.
4. Per-object locks and critical sections
The GIL used to make list.append, dict.__setitem__ and friends safe by accident. The free-threaded
build makes them safe on purpose: each object has a one-byte lock (ob_mutex) and C code wraps mutating
operations in critical sections (Py_BEGIN_CRITICAL_SECTION). Critical sections are automatically
suspended when a thread blocks (e.g. on another lock), which avoids lock-ordering deadlocks. Most reads of lists and
dicts are lock-free, protected by quiescent-state-based reclamation (QSBR) so memory is never freed under a reader.
What this guarantees is memory safety of the interpreter, not atomicity of your program: d[k] += 1
is still a read-modify-write race, exactly as it was with the GIL - only the interleavings become more frequent. That is why
contended_dict_update above uses an explicit threading.Lock, and why
contended_list_append shows the cost of taking the list's lock on every call.
5. The interpreter loop
The specialising adaptive interpreter rewrites bytecodes in place, which is a data race if two threads run the same code
object. 3.14t solves this with thread-local bytecode (-X tlbc, PYTHON_TLBC): each
thread specialises its own copy. This is what brought single-thread overhead down from the ~40% reported for 3.13t. The
"single-threaded overhead" finding above does not isolate it: its figure against 3.12 also carries every other
3.12→3.14 change, and its figure against "3.14 (GIL on)" compares the free-threaded binary with itself.
The experimental JIT is not built for the free-threaded build. sys._jit.is_available() is
False on the binary measured here - though that call only reports whether this binary was compiled
with the JIT, so it confirms the build rather than isolating free-threading as the reason.
Thread switching no longer exists as a concept. sys.setswitchinterval still exists and still accepts a
value, but with the GIL off there is no hand-off for it to govern - threads simply run.
6. Runtime knobs and semantics visible from Python
sysconfig.get_config_var("Py_GIL_DISABLED") - is this a free-threaded build?
sys._is_gil_enabled() - is the GIL on right now? (It can be re-enabled at runtime, see §7.)
PYTHON_GIL=0|1 / -X gil=0|1 and sys.flags.gil - override the build default. This
report's "3.14 (GIL on)" column is the free-threaded binary run with PYTHON_GIL=1: same allocator, same headers,
same locks - only the GIL is re-added. It isolates the GIL's effect but is not identical to a stock 3.14 build.
On the free-threaded build new threads inherit the caller's context variables by default
(sys.flags.thread_inherit_context = 1) and warnings.catch_warnings becomes context-aware
(sys.flags.context_aware_warnings = 1), because module-global mutable state is no longer protected by the GIL.
concurrent.futures.ThreadPoolExecutor and plain threading.Thread are unchanged; they simply
scale now. asyncio is still single-threaded per loop, and multiprocessing still works.
7. C extensions and packaging
Extension modules must opt in by declaring Py_mod_gil = Py_MOD_GIL_NOT_USED (multi-phase init) or calling
PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED). If a module does not, CPython re-enables the GIL for
the whole process and emits a RuntimeWarning. That is precisely what check_gil_support.py
detects, and what happened with duckdb in this run.
Wheels are tagged with the t ABI (cp314-cp314t-..., SOABI=cpython-314t-darwin);
they are not interchangeable with cp314 wheels. The stable ABI (abi3) does not cover free-threaded
builds in 3.13/3.14, so every extension needs a dedicated build.
A pure-Python wheel (py3-none-any) runs on the free-threaded build automatically, but "runs" is not
"thread-safe": the library still has to protect its own global state.
Extensions that already released the GIL around long C loops (numpy's BLAS calls, DuckDB's query engine, scikit-learn's
Cython nogil blocks) scaled across threads before free-threading; the gain from 3.14t is for code that
holds the GIL - Python-level dispatch, small-array ops, per-element Python callbacks such as
DataFrame.apply, and ordinary Python code. Which side an operation falls on is not a property of the
library it lives in: numpy_small_ops and numpy_matmul above are the same library on opposite
sides, and pandas_groupby scales on every build.
8. Measured facts (every configuration on this machine)
measured fact
3.12
3.14 (GIL on)3.14 GIL
3.14t (free-threaded)3.14t
3.14t (PYTHON_GIL=0 forced)3.14t GIL=0
Python version
3.12.13
3.14.6
3.14.6
3.14.6
ABI flags (sys.abiflags)
t
t
t
SOABI
cpython-312-darwin
cpython-314t-darwin
cpython-314t-darwin
cpython-314t-darwin
Free-threaded build (Py_GIL_DISABLED)
False
True
True
True
GIL enabled at run
True
True
False
False
sys.flags.gil (None = no override; absent before 3.13)
None
1
None
0
sys.flags.thread_inherit_context
None
1
1
1
sys.flags.context_aware_warnings
None
1
1
1
JIT available (sys._jit)
None
False
False
False
gc.get_threshold()
[700, 10, 10]
[2000, 10, 10]
[2000, 10, 10]
[2000, 10, 10]
compiler
Clang 22.1.3
Clang 22.1.3
Clang 22.1.3
Clang 22.1.3
sys.getsizeof(object()) bytes
16
32
32
32
sys.getsizeof(1) bytes
28
44
44
44
sys.getsizeof(2**40) bytes
32
48
48
48
sys.getsizeof("abc") bytes
44
60
60
60
sys.getsizeof((1, 2)) bytes
56
64
64
64
sys.getsizeof([]) bytes
56
56
56
56
sys.getsizeof({}) bytes
64
64
64
64
sys.getsizeof(set()) bytes
216
216
216
216
sys.getsizeof(class instance) bytes
48
48
48
48
sys.getsizeof(__slots__ instance) bytes
48
48
48
48
sys.getsizeof(lambda) bytes
160
168
168
168
Methodology & how to reproduce
Each (configuration, workload) pair runs in a fresh subprocess so an import that re-enables the GIL cannot
leak into other measurements. Each thread count is timed 10x (--repeats, default 5); the
median is plotted, error bars show the min-max range, and tables give median ± sample standard
deviation. Cells whose coefficient of variation exceeds 10% are marked ~; treat differences smaller than the
error bars as noise.
Every workload returns a checksum that tests/ pins across thread counts, so splitting the work differently
cannot change how much of it there is: exactly for the integer checksums, and to six decimal places for
cpu_float, where float addition is not associative. One deliberate exception: io_sleep gives
each thread a fixed number of waits, so its total work grows with the thread count - a flat line there means the
waits overlapped, and its 1.0x "speed-up" is the good outcome, not the absence of one.
BLAS/OpenMP are pinned to one thread (OPENBLAS_NUM_THREADS=1 etc.) so scaling reflects Python threads only.
Every workload gets one untimed warm-up call first (cold caches, lazy imports, uvicorn start-up).
Thread counts: 1, 2, 4, 8 on a 10-core
Apple M5 (macOS-26.6.2-arm64-arm-64bit).
Appendix: interpreter GIL warnings captured during the run
3.14t (free-threaded) / duckdb_query
<frozen importlib._bootstrap>:491: RuntimeWarning: The global interpreter lock (GIL) has been enabled to load module '_duckdb', which has not declared that it can run safely without the GIL. To override this behavior and keep the GIL disabled (at your own risk), run with PYTHON_GIL=0 or -Xgil=0.