Free-threaded Python stops at the fast cores
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.
Table
| workers | processes | threads |
|---|---|---|
| 1 | 1.00ร | 1.00ร |
| 2 | 1.77ร | 1.78ร |
| 4 | 3.04ร | 3.03ร |
| 6 | 4.01ร | 3.95ร |
| 8 | 4.52ร | 3.78ร |
| 10 | 5.18ร | 3.86ร |
| 12 | 5.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:
- The OS scheduler. The same C loop as 12 threads or as 12 processes takes the same time (5.87 vs 5.79 seconds). So do a C loop shaped like an interpreter (indirect calls through a table, reads and writes to private memory) and one with 1.9 MB of distinct functions to fill the instruction caches. Threads and processes stay within 7% of each other for both.
- Idle cores or low clocks. During a 12-worker run, macOS's
powermetricsshowed all 12 cores close to 100% busy in both setups, at the same efficiency-cluster clock, with the fast cluster a little higher for threads. The threads are running; they get less done per cycle. Measured withtime, they used about a third more CPU time for the same work, almost none of it in the kernel. - C code in threads. Hashing with SHA-256 from 12 threads runs as fast as 12 processes (0.135 vs 0.134 seconds). The slowdown showed up in every bytecode loop I tried and in none of the C code.
- Memory allocation. The test loop allocates a new integer every step. A loop that only touches cached small integers has the same gap, and so does an empty
for _ in repeat(None, n): pass: 0.67 seconds as 12 threads, 0.46 as 12 processes, and 0.60 for just 6 threads. - Shared objects. Giving each thread its own freshly compiled copy of the function changed nothing. Neither did switching off 3.14's per-thread copies of the bytecode (
PYTHON_TLBC=0), or making the loop yield a thread-owned object or a small integer instead ofNone. - A 3.14 regression. Python 3.13t has the same shape: 0.26 seconds on 6 threads, 0.28 on 12, 0.19 on 12 processes.
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.