PyData PyCon Yerevan 202601 / 26

No GIL,
Real Gains

Porting a C++ Python extension to free-threaded Python 3.14.

Auxten Wang · Technical Director @ ClickHouse · Creator of chDB

PyData PyCon Yerevan · 24 Jul 2026

GIL
3.14t
16 cores, one interpreter,
zero subprocesses
About me02 / 26
Auxten Wang

Auxten Wang

@auxten · auxten.com

Technical Director @ ClickHouse
Creator of chDB — acquired by ClickHouse in 2024
20+ years of C/C++ · systems & data infrastructure
Ex-Shopee · CovenantSQL · Baidu · 360 · 4Paradigm
A little history

Killing the GIL: 30 years of attempts

1996 free-threading patch ✗ too slow 2007 python-safethread ✗ too restrictive 2016 Gilectomy ✗ 19× slower 2021 nogil fork ✓ PEP 703 2025 3.14t supported you are here
Guido's bar (2007): remove the GIL only if single-threaded performance does not decrease.
Every attempt died on that bar — until one didn't. Here's each autopsy.
1996 · Attempt #1
1996

Greg Stein's free-threading patch

One big lock → a small lock on every mutable structure. Python 1.4.

✗ ~2× slower single-threaded — shelved.

One survivor: PyThreadState — still in CPython today.

=== comp.lang.python · 1996 ===

From:    Greg Stein
Subject: free threading patches
         for Python 1.4

A patch set that removes the
global interpreter lock and
guards dicts, lists, modules…
with fine-grained locks.

> benchmark: ~2x slowdown_

2007 · Attempt #2
2007

Adam Olsen's python-safethread

Sidestep the locks: every object is owned by one thread. Sharing only through monitors.

✗ Safe — but you couldn't share anything freely. Faded away.
Thread 1 owns its objects Thread 2 owns its objects monitor (the only door) ✗ no direct sharing
2016 · Attempt #3
2016

Larry Hastings' Gilectomy

Make every refcount atomic. Now each Py_INCREF is a fight between CPU cores.

4.4 s → 83 s

18.9× slower on 7 cores. Abandoned.

Larry Hastings on stage at PyCon 2016

Larry Hastings · PyCon 2016, Portland

Core 0 Py_INCREF(obj) Core 1 Py_DECREF(obj) ob_refcnt one cache line ping… pong… ping… every incref/decref = intercore bus traffic
2021 · Attempt #4
2021

Sam Gross' nogil fork

Don't make refcounts atomic — redesign them so the common case never leaves the thread.

✓ Single-thread cost finally small → became PEP 703.
Sam Gross

Sam Gross · author of the nogil fork & PEP 703

PyObject — biased refcounting owner thread count++ (plain) everyone else atomic (rare) fast path stays on one core — no ping-pong immortal objects: never counted at all None  True  42  ''  →  refcnt = ∞
Python 3.14t04 / 26

The one that landed: free-threaded Python

PEP 703 · 3.13

GIL becomes optional. Experimental, ~40% single-thread penalty.

PEP 779 · 3.14

Officially supported. Specializing interpreter on; penalty ~5–10%.

Opt-in build

Separate binary python3.14t; wheels get a cp314t suffix.

$ python3.14t -c "import sys; print(sys._is_gil_enabled())"
False        # threads now run Python bytecode in parallel
Phase III — making it the default Python — is on the roadmap. This train is leaving.
Why it matters05 / 26

Why it matters: Python finally spans the cores

Single-core speed plateaued; machines got wider. Free-threading lets one process use the width.

Agent builders

Parallel subagents in one process — shared models. No pickle, no IPC.

Desktop apps

Responsive UI + truly parallel background work.

Servers & data tools

One copy of the model — not 16 workers × 16 copies.

Same language, same process, 16× the compute — at a cost to extension authors.
Before no-GIL

20 years of dodging the GIL: the four escape hatches

Drop to C

GIL released C / BLAS — all cores

NumPy & SciPy: Py_BEGIN_ALLOW_THREADS around every kernel.

Thread pool below Python

Python: 1 thread, just orchestrates

TensorFlow & PyTorch: C++ thread pools, CUDA streams — the graph runs outside Python.

Fork & pickle

proc 1 proc 2 proc 3 copies cross — the pickle tax

multiprocessing, joblib, DataLoader workers — every value crosses by copy.

Compile it away

with nogil:      # Cython
    crunch(buf)

@njit(nogil=True)  # Numba
def crunch(buf): ...

Leave the interpreter entirely — then threads are free.

Four hatches, one rule: never touch Python objects from a thread.
Before no-GIL

It works — until the work is Python

Python — one thread, holds the GIL, orchestrates df.groupby(...)  model(x)  query(sql) the GIL toll gate objects cross one at a time C / C++ / CUDA — 16+ threads, embarrassingly parallel BLAS kernels · Eigen & ATen pools · ClickHouse pipelines · OpenMP Python callback / UDF → back through the gate object-dtype column → every cell is a PyObject strings → PyUnicode, one by one per-batch glue code → serial again
The moment threads need Python objects, every library hits the same wall. That wall is what 3.14t removes.
The catch06 / 26

Extensions get nothing for free

One bad import poisons the well. Any un-opted-in extension → CPython silently turns the GIL back on for everyone.
"Thread-safe because GIL" is now a data race. Every global, cache and lazy init loses its silent protection.
Your abi3 wheel won't load. No stable ABI on 3.14t — new wheels, new CI.
$ python3.14t -c "import _chdb"
RuntimeWarning: The global interpreter lock (GIL) has been enabled to load
module '_chdb', which has not declared that it can run safely without the GIL.
Free-threading is opt-in, per extension module. This talk: what opting in actually took.
Why it's hard · 1/2

Touching an object used to be free. Not anymore.

Thread A Thread B item = PyList_GetItem(L, 0) borrowed ref — refcount NOT taken L.clear() refcnt → 0 — object freed use(item)  →  💥 freed memory use-after-free, random crash With the GIL, this interleaving was impossible — B couldn't run while A held the lock. On 3.14t it's Tuesday.
PyList_GetItem · borrowed PyList_GetItemRef · strong — whole C-API families had to change
Why it's hard · 2/2

The black magic that keeps 3.14t fast

Biased refcounting

owner ++ others atomic

Two counters. The hot one never leaves its core.

Immortal objects

None True 42 refcnt = ∞

The hottest objects opt out of counting entirely.

Per-object locks

dict / list

Mutations lock one object, not the world — deadlock-proof critical sections.

Lock-free reads

d[k]  d[k]  d[k] no lock taken memory reclaimed only when readers are done (QSBR)

dict/list reads stay wait-free on the hot path.

This cocktail is why 3.14t costs ~5–10% where Gilectomy cost 19×.
Why it's hard · the fallout

Every big library re-did its object plumbing

Who
What they actually did
CPython C API
New primitives: PyMutex, critical sections, borrowed→strong *_GetItemRef
NumPy
Audited C globals & caches; locks ported to PyMutex
PyTorch
ATen & autograd audit → cp314t wheels since 2.10
TensorFlow
Hasn't — its parallelism lives below Python anyway
Cython
One directive: freethreading_compatible=True; cython.pymutex for hot spots
PyO3 (Rust)
Data races won't compile — Send/Sync checked by the compiler
chDB
…that's the rest of this talk
Not app code — plumbing, done once per library. 99% of Python users never see it.
Ecosystem check07 / 26

So who has opted in? (July 2026)

61%

of the top 360 compiled packages on PyPI now ship free-threaded wheels

219 / 360 · hugovk.dev/free-threaded-wheels · 15 Jul 2026

The scientific stack led the way. Chances are your daily imports already run free-threaded:

NumPySciPypandasscikit-learnPyArrowMatplotlibPillowPyTorchnumbaSQLAlchemylxmlaiohttpuvlooppydantic-corecryptographytiktokenpsutilregex
The tooling has converged too: manylinux dropped 3.13t in May 2026 — 3.14t is the free-threaded target now.
Ecosystem check08 / 26

…and who hasn't (yet)

Package
FT wheels
Status
NumPy
First mover — wheels since 2.1 (Aug 2024)
pandas
cp314t since 2.3.3 — Windows wheels still missing
PyTorch
Preview 2.9 → real cp314t wheels in 2.10 (Jan 2026)
chDB
That's this talk
Polars
Importing it re-enables the GIL for the whole process
grpcio · protobuf
Blocks every gRPC-dependent tree (much of the AI stack)
TensorFlow · DuckDB · tokenizers · orjson
Not yet — watch the tracker
One in your dependency tree → the whole process quietly runs GIL-on. Trust nothing — check sys._is_gil_enabled() at runtime.
chDB09 / 26

Our patient — chDB: a rocket engine on a bicycle

The ClickHouse engine, embedded in your Python process — SQLite-simple, built to scan millions of rows.

# pip install chdb
import chdb
chdb.query("SELECT count() FROM file('hits.parquet')")
In-process: no server, no ports — just a module.
Same engine: ClickHouse SQL, JOINs, vectors.
Multi-threaded C++ inside — remember this bit.
A rocket engine on a bicycle
Local like SQLite. Analytical like ClickHouse.
chDB10 / 26

It drops into your Python data stack

70+ formats

Parquet, CSV, JSON, Arrow, ORC — read & write, no converters.

Zero-copy Pandas / Arrow

Query a live DataFrame in place: SELECT … FROM Python(df)

Python UDFs

Your Python functions callable from SQL — keep this one in mind too.

No ClickHouse knowledge needed today. For this talk, chDB is just "a multi-threaded C++ engine behind pybind11, reading Python objects."
The paradox11 / 26

Parallel engine × serial glue = serial query

The engine scales fine — every stream just queues on the GIL to touch Python.

16 parallel engine threads queue single-file at the GIL to reach Python UDFs, DataFrame columns and strings
The GIL didn't slow our engine down. It serialized every conversation with Python.
The plan12 / 26

Three changes that made no-GIL pay off

01

Move Python UDF execution in-process

Kill the subprocess pipeline first.

02

Declare the module GIL-independent

Py_mod_gil_not_used — then survive the audit it implies.

03

Drop to low-level CPython string access

On hot paths, the friendly Unicode APIs had to go.

chDB behind the GIL bottleneck vs parallel UDFs, strings and benchmarks on free-threaded Python 3.14

Plus the honest part: what broke, what didn't scale, and where the stable ABI stopped being an option.

Change #1 · UDFs13 / 26

UDFs ran out of process

The old decorator wrote a script + XML config; the engine forked Python and piped rows through it as text.

Fork + interpreter startup on the query path
Rows serialized as TabSeparated text over stdin/stdout
Every argument arrives as str — no types
No shared state with your process; imports re-run
The GIL wasn't even the bottleneck yet — the fork() was.
Before: engine forks a Python subprocess and pipes rows as text. After: the Python function runs in-process inside the engine.
Change #1 · UDFs14 / 26

After: native in-process UDFs

C++ holds your py::function and calls it from the engine pipeline. Typed args, zero subprocesses.

import chdb
from chdb.sqltypes import INT64

@chdb.func([INT64, INT64], INT64)  # in-process
def add(a, b):
    return a + b

chdb.query("SELECT add(x, y) FROM t")
// C++: PythonScalarUDF::executeImpl
// (called from engine worker threads)
py::gil_scoped_acquire acquire;   // <-- THE lock

for (size_t row = 0; row < rows; ++row)
{
    py::tuple py_args = box_arguments(row);
    py::object out    = func(*py_args); // your fn
    insert_into_column(result, out);
}
Faster already — but see that gil_scoped_acquire? On a stock build, every engine thread queues on it.
Change #1 · UDFs15 / 26

This is what no-GIL was made for

Stock 3.14: threads take turns running Python. Free-threaded 3.14t: all threads run Python in parallel.
Stock 3.14: 16 streams hit the UDF → threads take turns on one GIL — flat ~0.85 s no matter how many threads.
3.14t: the same acquire becomes per-thread bookkeeping — 0.72 → 0.15 s from 1→8 threads, holds at 32.
Your UDF must be thread-safe too — it now runs concurrently.
Per-row boxing still costs — PyObject_Vectorcall + a reused arg frame cut it 4.7× (chdb-core#142).

4M-row scalar UDF, 16-core x86_64, best of 3 — post-fix numbers, landing in 26.5.1.

Order matters: go in-process first, then remove the GIL. A subprocess pipeline gains nothing from free-threading.
Change #2 · Opt in16 / 26

Opting in: one line — and an audit

PYBIND11_MODULE(_chdb, m, py::mod_gil_not_used())   // that's the whole declaration

The line is easy. It's a claim about every global in your codebase — so the real work was a state audit:

Import cache, UDF registry, engine singleton — every "thread-safe because GIL" spot got a real lock (call_once, shared_mutex).
Textbook C++ concurrency — the hard part is finding it, not fixing it.
Our import cache's "safe" lock-free fast path had been a data race for years. The GIL was its alibi.
None of the fixes were exotic. The GIL was just hiding the to-do list.
Change #2 · War story17 / 26

The nastiest bug: lazy init on a GIL-free thread

Trigger

First NULL in a string column → a scan thread lazily imports pandas.NaT… with no Python thread state.

Crash

Segfault deep inside the allocator — nowhere near the actual bug.

Hang

Then the fatal-signal handler deadlocks. No traceback, no exit. The query just hangs, forever.

The fix was two lines: resolve everything eagerly, while you still hold the GIL. Finding it took days — debugging silence is the expensive part.
Change #3 · Strings18 / 26

Strings are the hot path — go straight to the bytes

Millions of strings cross the C++/Python border per query. Friendly Unicode APIs cost a call per value — so on 3.14t we read CPython's memory directly:

if (PyUnicode_IS_COMPACT_ASCII(obj)) {                    // most analytical data
    data = (const char *) PyUnicode_1BYTE_DATA(obj);      // direct pointer, no copy
    len  = PyUnicode_GET_LENGTH(obj);
}
// ...cached-UTF8 / compact / legacy fallbacks omitted
ASCII string → a pointer read. No shim, no allocation, safe from any thread.
Free-threaded builds can't use the stable ABI anyway — nothing left to lose by going low-level.
Rule of thumb on hot paths: the friendlier the API, the more it costs per row.
Change #3 · Strings19 / 26

The biggest win: skip PyObject entirely

pandas 3.x str columns are Arrow-backed: plain C buffers, scanned directly on all streams — no GIL at all.

Slow path: one PyObject per row behind the GIL. Fast path: zero-copy from Arrow validity/offsets/data buffers straight into ColumnString, GIL-free on all threads.
object dtype (before) — 219.2 s
Arrow zero-copy (after) — 20.1 s

ClickBench, 50M-row hits DataFrame, all queries, pandas 3.x, hot run · 10.9× total · geomean 4.5× · string-heavy up to 64×

Priority order on hot paths: no PyObject > cheap PyObject access > friendly PyObject APIs.
Scar tissue20 / 26

What broke, what surprised us

The silent GIL re-enable got us too. One undeclared dep → our "no-GIL benchmark" measured the GIL.
Crashes hide as hangs. Allocator segfault → signal-handler deadlock. Budget time to debug silence.
New wheels, new CI. No stable ABI on 3.14t → separate cp314t lanes, doubled matrix.
Already-parallel code got no faster. Only Python touchpoints improved; single-threaded ~5–10% slower.
Cold start + high threads hung. Fresh 3.14t process, first UDF at max_threads=32 — GC's stop-the-world deadlocked against an import-cache call_once (chdb-core#131, fixed: detach before you block).
Free-threading didn't create these bugs. It revoked the amnesty they'd been living under.
Benchmarks21 / 26

Measured, not vibes

The free-threading win, isolated: an object-dtype scan (5M rows) that must read one PyObject per value — exactly where the GIL used to serialize every stream.

stock 3.14 · 16 threads
0.22 s
3.14t · 16 threads
0.08 s
On stock 3.14, adding threads hurt (0.13 → 0.22 s as they pile onto the GIL). On 3.14t the same threads help2.6× faster, and the pure-engine query stays at parity (0.161 s).

chdb-core 26.5.1-rc.2 cp314t wheel, CPython 3.14.6 vs 3.14.6t, 56-vCPU Xeon E5-2690v4, min of 6 runs. Both rc sharp edges are fixed for 26.5.1: the FT UDF collapse past 8 threads (chdb-core#128 — per-row tuples were feeding stop-the-world GC storms) and the cold-start hang (#131). That's what benchmarks are for.

Decision guide22 / 26

When does no-GIL actually help?

It pays when…

Many threads must execute Python (UDFs, callbacks)
Many threads must read PyObjects (object-dtype columns)
The GIL was your measured bottleneck, not a hunch

It doesn't when…

Your C++ already releases the GIL for the heavy work
You can avoid Python objects entirely (Arrow, buffers, NumPy)
The workload is single-threaded — you pay ~5–10% for nothing
Best result in our port: zero-copy beat lock-removal. Free-threading fixes what zero-copy can't reach — running actual Python in parallel.
Your move23 / 26

You don't need to port anything. Try it this week:

# 1 · install
uv python install 3.14t
uv venv --python 3.14t && uv pip install -r requirements.txt
# 2 · run YOUR tests, unchanged — watch for the GIL warning
python -m pytest
# 3 · trust, but verify
python -c "import sys, yourapp; print(sys._is_gil_enabled())"
Your existing ThreadPoolExecutor code doesn't change — it just starts scaling.

How to help the community

Report breakage on 3.14t — maintainers' #1 ask.
Upvote / file wheel issues on deps that block you.
Publish your numbers — "N× faster" moves roadmaps.
Maintain a C/C++/Rust extension? Full checklist: py-free-threading.github.io
Close24 / 26

Three takeaways

01

Free-threading reaches you through your dependencies.

Your job isn't porting — it's testing on 3.14t, verifying with sys._is_gil_enabled(), and reporting what breaks.

02

Restructure before you parallelize.

In-process beats out-of-process; zero-copy beats lock-free; then free-threading multiplies what's left.

03

The GIL was an amnesty, not a feature.

3.14t revokes it. The bugs it exposes were already yours.

Thank you25 / 26

Take 3.14t for a spin

pip install chdb          # try the engine
python3.14t your_tests.py # try the future

Code & slides

github.com/chdb-io/chdb
auxten.com · @auxten

Read more

py-free-threading.github.io
PEP 703 · PEP 779 · chDB docs: clickhouse.com/docs/chdb

Python can finally scale across cores.
Now it's our extensions' turn.
Q&A26 / 26

Q&A

github.com/chdb-io/chdb  ·  auxten.com

No GIL, real gains — thank you, Yerevan!