Snapdragon X Elite 12-core scaling: 12 workers were slower than 8
In this article
On the Snapdragon X Elite, ProcessPoolExecutor reached 37.26 Mops at both 8 and 10 workers, then fell to 34.25 Mops at 12 workers.
I had assumed without question that a 12-core CPU should scale naturally to 12 workers. I had read the simplistic phrase "the number of cores equals the optimal worker count" somewhere and accepted it. But using the 1-worker result of 5.61 Mops as the baseline, 8 workers gave 6.65x, 10 workers also stopped at 6.65x, and 12 workers retreated to 6.11x. Reaching less than the ideal 12x was understandable; I had not expected the last step to become slower.
This was Windows ARM64 on a Surface Pro with Python 3.12.10. The actual workload was only a light integer loop sent to multiple processes. Even so, the ceiling was not 12 workers, which made me question the habit of deciding that "just start one per core" is always right.
What I ran
Each worker ran a 3000000-iteration integer loop, with workers set to 1, 2, 3, 4, 6, 8, 10, and 12. I ran each condition three times. I keep all three runs in the table, but the text focuses on the median.
from concurrent.futures import ProcessPoolExecutor
WORK_PER_WORKER = 3000000
WORKERS = [1, 2, 3, 4, 6, 8, 10, 12]
def burn(n):
total = 0
for i in range(n):
total += (i * 17) % 97
return total
for workers in WORKERS:
with ProcessPoolExecutor(max_workers=workers) as executor:
list(executor.map(burn, [WORK_PER_WORKER] * workers))
I ran it around 20:17 on June 2, 2026 and used that one measurement from that day. A rerun on another day might produce slightly different numbers, but I did not pursue that.
This measures more than pure CPU arithmetic: ProcessPoolExecutor process startup, task submission, and result collection are all included. Startup cost was not separated, which is the biggest weakness of this article. The more workers there are, the more strongly those costs are mixed in, so it would be unreasonable to generalize this to "how Python integer arithmetic scales across 12 cores."
Numbers
| Workers | runs_s | median_s | throughput_Mops | speedup_vs_1 |
|---|---|---|---|---|
| 1 | 0.576 / 0.529 / 0.535 | 0.535 | 5.61 | 1.00 |
| 2 | 0.544 / 0.553 / 0.546 | 0.546 | 10.99 | 1.96 |
| 3 | 0.578 / 0.547 / 0.577 | 0.577 | 15.59 | 2.78 |
| 4 | 0.556 / 0.548 / 0.523 | 0.548 | 21.89 | 3.90 |
| 6 | 0.576 / 0.608 / 0.592 | 0.592 | 30.41 | 5.42 |
| 8 | 0.640 / 0.677 / 0.644 | 0.644 | 37.26 | 6.65 |
| 10 | 0.805 / 0.728 / 0.843 | 0.805 | 37.26 | 6.65 |
| 12 | 1.051 / 1.044 / 1.057 | 1.051 | 34.25 | 6.11 |
From 1 through 4 workers, the scaling is clean. 5.61 Mops became 21.89 Mops, with a 3.90x speedup. At 6 workers it was 30.41 Mops and 5.42x, so the numbers still suggested room to grow.
The point that caught me was after 8 workers. Reaching 37.26 Mops at 8 was fine, but 10 workers remained at 37.26 Mops with no movement. The 10-worker median was actually longer, 0.805 seconds versus 0.644 seconds at 8. The total throughput only looks unchanged because the amount of work also increased; that is all the table establishes.
At 12 workers it got worse again. The median increased to 1.051 seconds and throughput fell to 34.25 Mops. The speedup versus 1 worker dropped to 6.11x, below the 8-worker 6.65x. Putting 12 workers on a 12-core CPU and getting a slower result than 8 workers was the most surprising part of this measurement.
Where did it stop scaling?
Within this data, the natural reading is that the ceiling was around 8 workers. Both 8 and 10 workers reached 37.26 Mops, while 12 fell to 34.25 Mops.
However, I cannot confidently say that 8 workers is the answer. I tested only 1, 2, 3, 4, 6, 8, 10, and 12; 5, 7, 9, and 11 are missing. That was a complete measurement-design mistake. I did not collect enough evidence to tell whether the peak is exactly 8 or whether it was already descending from a peak around 7.
The table proves only this sequence: scaling stopped at 8 workers, did not move at 10, and fell at 12. Explaining why requires another measurement.
What I could not determine
The biggest failure was that my initial prediction was too simple. If I had assumed that 12 workers must be the maximum on a 12-core CPU, I would have adopted the slower 34.25 Mops setting. Measuring it prevented that, which is the honest benefit I got from the test.
I also remain concerned that ProcessPoolExecutor startup cost is not separated. I took the median of three runs, but process creation remains included. More workers mean more processes to create, so the measurement structurally disadvantages 12 workers. I noticed that only afterward.
I will not decide the actual reason for the slowdown at 12 workers here. The OS threads, the measuring parent process, or something running in the background may have consumed some CPU. Filling all 12 cores leaves less room for those surrounding tasks. That is plausible, but the JSON contains neither CPU utilization breakdown nor scheduler behavior, so it remains speculation. Because this was only one measurement on the evening of June 2, it would be unsafe to make a definitive claim.
I would like to repeat it. Creating the pool first and sending multiple batches would dilute startup cost, and adding 5, 7, 9, and 11 workers would reveal the shape of the peak. That is beyond the scope of this article, so I left it for later.
How I changed my default habit
When sending CPU-heavy work to ProcessPoolExecutor, I no longer immediately write 12 workers. I start with 8, then briefly measure 10 and 12 for a heavier workload. If it stops improving, I go back to 8. I want to change the habit of increasing the count without measuring.
For clarity, this is not a claim that the Snapdragon X Elite is slow. A single worker achieved 5.61 Mops and eight workers reached 37.26 Mops, so multiprocessing works properly even on ARM64 Windows with Python 3.12.10.
The caution applies only to the last few workers. Filling every apparently available core made the workload slower, at least in my environment. The difference between 1.051 seconds at 12 workers and 0.644 seconds at 8 was large enough to be noticeable.
For a similar script, I will start max_workers at 8 instead of fixing it at 12. I will briefly measure 10 and 12, then go back if they do not improve. Adding one small benchmark can greatly reduce the chance of continuing to use a slow setting while believing it is the right one.