โ† back to journal

Free-threaded Python stops at the fast cores

2026-10-12 ยท Kit Wickham

Update, later the same day: found the cause. It's false sharing on CPython's per-thread QSBR slots, which are padded to 64 bytes on a chip with 128-byte cache lines.

Python 3.14 has an official build without the GIL, python3.14t, so plain Python threads can run on several cores at once. I ran a CPU-bound loop on an Apple chip with 12 cores in three speed tiers. Threads kept pace with separate processes up to 6 workers, which is the number of fast cores. Past that, processes kept speeding up and threads didn't: at 12 workers, processes reached 5.8ร— and threads 4.2ร—. I ruled out a long list of causes, including the OS scheduler, but I haven't found the real one.

The chip and the test

macOS reports the 12 cores as three tiers: 2 "Super", 4 "Performance" and 6 "Efficiency". The test loop is s += i * i % 7 over a range, 600 million iterations in total, split evenly across the workers. Each setup ran three times and I kept the fastest. "Threads" means one free-threaded process with N threads. "Processes" means N forked copies of the same free-threaded interpreter, one thread each. With one worker both took 13.2 seconds.

processesthreads
Speedup over one worker, best of three runs. The dashed line marks where the 6 fast cores run out.
Table
workersprocessesthreads
11.00ร—1.00ร—
21.77ร—1.78ร—
43.04ร—3.03ร—
64.01ร—3.95ร—
84.52ร—3.78ร—
105.18ร—3.86ร—
125.79ร—4.15ร—

Up to 6 workers the two lines are on top of each other. That part is good news for free-threading: on fast cores, a thread is as good as a process. At 12 workers, threads took 3.17 seconds and processes 2.27, so processes did 40% more work in the same time. Threads at 7 to 10 workers were slightly slower than at 6.

The ceiling itself is hardware, not Python. A C loop scales to 4.5ร— on 6 threads and 5.6ร— on 12, because the fast cores slow down when they're all busy and the efficiency cores are slower to begin with. Python processes get the same kind of help from the efficiency cores. Python threads in one process don't.

What it isn't

The obvious suspects each got their own test:

What it needs

The gap needs both kinds of core. When I pushed a 6-thread run onto the efficiency cores only (macOS's taskpolicy -b), threads and processes tied (0.94 vs 0.97 seconds). On the 6 fast cores they tie as well, as the chart shows. It's when one process's threads run on fast and slow cores at the same time that each thread does less. Splitting the 12 workers into more processes helps step by step: 1 process of 12 threads took 2.13 seconds, 2 of 6 took 2.04, 3 of 4 took 1.81, and 12 of 1 took 1.49 (a smaller run of the same loop).

That pattern looks like something the interpreter writes from every thread into memory that the other threads read: cheap to share between the fast cores, expensive once the efficiency cores are involved. CPython does pad some per-thread state to 64 bytes against false sharing, and this chip's cache lines are 128, but the structures I could check don't explain it. The per-thread interpreter states, for example, sit 16 KB apart. A profile of the empty loop shows the same few bytecode handlers in both setups, just slower in threads. Finding the shared line would take hardware counters, which I couldn't read here. So: measured effect, unknown cause.

What to do about it

On a Mac with efficiency cores, a free-threaded pool for CPU-bound Python should stop at the number of fast cores. sysctl hw.perflevel0.physicalcpu hw.perflevel1.physicalcpu gives the counts per tier; here that's 2 + 4. If you need more than that, use processes. Processes on the free-threaded build cost nothing extra: 12 of them finished in 1.49 seconds, against 1.54 on the regular GIL build.

Free-threading still does what it promises up to that point: 4ร— on 6 threads, where the GIL build gets 1ร—. The cores past that point are where a process pool still wins.