โ† back to journal

Free-threaded Python 3.14 scales a pure-Python loop 4ร— on 8 threads

2026-10-14 ยท Kit Wickham

I split one pure-Python CPU loop across 1, 2, 4 and 8 threads, once on the normal CPython 3.14.8 and once on its free-threaded build (3.14t, GIL off). The normal build took 1.2 s no matter how many threads shared the work. The free-threaded build went from 1.16 s on one thread to 0.28 s on eight, a 4.1ร— speedup. It did not scale linearly, and it did not get slower on a single thread either.

The test

Each thread runs for i in range(n): s += i*i % 7 on its own local variable, and the total work is fixed at 40 million iterations, divided evenly among the threads. No shared state, no locks. I took the best of three runs per thread count, in a Linux aarch64 container with 10 cores, with nothing else running.

threadsGIL build (s)free-threaded (s)speedup vs 1 thread
11.2191.1581.0ร—
21.2280.6191.9ร—
41.2330.3962.9ร—
81.2240.2804.1ร—

What stands out

The GIL column is flat to within 1%: extra threads cost nothing and gain nothing, which is the textbook behavior. The free-threaded column scales well to two threads (1.9ร—) and then falls off. Eight threads reach 4.1ร—, not 8ร—. I didn't dig into why. Some of it is probably that the container's 10 cores include efficiency cores, and some may be the free-threaded interpreter's per-object reference counting, but I didn't test either guess.

The single-thread number is the one I'd have worried about. Free-threaded builds were once reported as noticeably slower on one thread. Here the free-threaded build was 5% faster, which I'd treat as a tie. This loop touches only small integers and one local variable, so it says little about code that hammers shared dicts or lists.

Takeaway

If you have CPU-bound pure-Python work that you've been pushing into multiprocessing only to dodge the GIL, a plain threading version on 3.14t is worth trying. It's about 30 MB to install with uv python install 3.14t. Check that your C-extension dependencies have free-threaded wheels first, because importing one that doesn't can turn the GIL back on for the whole process. You can see which state you're in with sys._is_gil_enabled().

Method: CPython 3.14.8 and 3.14.8+freethreaded from the same uv release, one synthetic integer loop, one machine, best of three. Memory-bound code, shared mutable state and real libraries could all scale differently.