The same code ran 3x slower because macOS put it on the wrong cores
You cannot pin a thread to a performance core on Apple Silicon. You can tell macOS how important the work is, and that decision cost this benchmark a factor of three.
A single-threaded loop compiled once and run six times on an idle machine took 0.865 seconds on average, and 2.61 seconds on average. Same binary, same machine, same power state, same minute. The only difference was a quality-of-service class, and it cost a factor of 3.02.
This is what the performance and efficiency core split actually feels like when the scheduler makes the wrong call about your work.
The test
A deliberately simple CPU-bound loop with no memory pressure and no I/O, so the only variable is where it executes:
#include <stdio.h>
int main(void){
volatile double x = 0;
for (long i = 0; i < 400000000L; i++) x += i * 0.5;
printf("%f\n", x);
return 0;
}
clang -O1 -o spin spin.c
# Default quality of service
/usr/bin/time -p ./spin
# Background quality of service
/usr/bin/time -p taskpolicy -b ./spin
taskpolicy -b runs a process in the background QoS band, which is macOS’s way of saying nobody is waiting for this.
Every timing
| Run | Default QoS | Background QoS |
|---|---|---|
| 1 | 1.40 s | 2.67 s |
| 2 | 0.86 s | 2.35 s |
| 3 | 0.87 s | 2.81 s |
The first default run at 1.40 s is a cold start: the binary was not yet in the page cache and the branch predictors had no history. Runs 2 and 3 are the steady state at 0.865 s, and they agree with each other to within 10 ms.
The background runs never converge like that. They range from 2.35 s to 2.81 s, a spread of 460 ms, which is more than half the entire duration of a default run. That instability is itself informative and is discussed below.
Why the work moved
Apple Silicon has two core types. On this M4 Pro that is 10 performance cores and 4 efficiency cores, and they differ in more than clock speed: the efficiency cores report half the L1 data cache and a quarter of the shared L2 of their performance counterparts.
macOS does not let you choose between them. There is no thread affinity API that pins work to a core, and this is deliberate. What you get instead is a declaration of intent, the quality-of-service class, and the scheduler maps that onto hardware however it sees fit.
The classes, in descending urgency, are user-interactive, user-initiated, default, utility and background. Work at the top runs on performance cores. Work at the bottom is confined to efficiency cores, and is additionally subject to throttling and to being descheduled in favour of anything more urgent.
taskpolicy -b puts the process in that bottom band. The 3.02x figure is the price of the two smaller caches, the lower clock, and the scheduler’s willingness to interrupt.
Why background timings are unstable
Default-QoS runs varied by 10 ms. Background runs varied by 460 ms.
Work in the background band is not merely slower, it is interruptible. The scheduler is free to pause it when anything more urgent appears, and on a machine running 548 processes there is always something. Between runs, the amount of interference differs, so the completion time differs.
That property is the point of the band rather than a defect in it. Background work is supposed to yield. It means a background timing is not a stable measurement of anything, and anyone benchmarking at background QoS is measuring the rest of the system as much as their own code.
When each class is correct
Efficiency cores exist because most of what a computer does is not urgent. Spotlight indexing, Time Machine, mail fetching, software update downloads, photo analysis: all of it can take three times longer at a fraction of the power, and nobody notices. On a laptop that trade is obviously right.
The failure mode is applying it to work someone is waiting for. A progress bar that advances three times slower than it should, a build that takes minutes longer, an export that stalls whenever anything else happens. In each case the code is fine and the class is wrong.
For a developer the practical rules are short. Anything driving a visible interface belongs at user-interactive. Anything a person triggered and is waiting on belongs at user-initiated. Anything speculative or scheduled belongs at utility or background. Getting this wrong in either direction is costly: too low and the user waits, too high and battery life suffers for work nobody asked about.
Reproducing it
cat > spin.c <<'EOF'
#include <stdio.h>
int main(void){ volatile double x=0; for(long i=0;i<400000000L;i++) x+=i*0.5; printf("%f\n",x); return 0; }
EOF
clang -O1 -o spin spin.c
for i in 1 2 3; do /usr/bin/time -p ./spin 2>&1 >/dev/null | grep real; done
for i in 1 2 3; do /usr/bin/time -p taskpolicy -b ./spin 2>&1 >/dev/null | grep real; done
Discard the first default run as cold. Compare the rest. The ratio you get depends on your chip’s particular core mix, so an M4 with fewer performance cores or an M4 Max with more will not produce 3.02 exactly.
What this does not show
This measures one single-threaded, compute-bound, cache-friendly workload. A memory-bound workload would show a larger gap, because the efficiency cluster’s 4 MB of shared L2 against 16 MB would start to dominate. A workload spending most of its time waiting on I/O would show almost no gap at all, because neither core type is doing anything while a read completes.
The measurement also cannot confirm which physical cores were used. It shows the effect of the QoS class, and the effect is consistent with efficiency-core placement, but macOS does not report core residency to user space without powermetrics, which requires root. The honest claim is that background QoS made identical code three times slower, and that the documented mechanism for this is core placement.
Setting the class from code
taskpolicy is a blunt instrument for demonstrating the effect. Real software declares intent
per work item rather than per process.
With Grand Central Dispatch, the class is a property of the queue:
// Someone is looking at the result of this
DispatchQueue.global(qos: .userInitiated).async { render() }
// Nobody is waiting; take as long as you need
DispatchQueue.global(qos: .background).async { prefetch() }
The five classes, in descending urgency, are userInteractive, userInitiated, default,
utility and background. The first drives visible interface work and should be brief. The
second covers anything a person triggered and is waiting on. utility suits progress-bar work
that is expected to take a while. background is for anything speculative or scheduled.
The property propagates: work enqueued from within a block generally inherits the enclosing class, which is what makes a single misdeclared queue at the top of a chain able to slow an entire subsystem.
The mistake in both directions
Declaring too low is the failure this article measured: a factor of three on identical code, plus unpredictable completion times because background work yields to everything else.
Declaring too high is the failure that does not show up in testing and does show up in reviews.
Work pinned to performance cores runs at higher power, and a background task declared
userInitiated keeps the large cores awake for work nobody is waiting on. On a laptop that is
directly visible as battery life, and it is invisible on a desk with the charger plugged in,
which is where most software is written.
The efficiency cores exist precisely so that indexing, syncing, prefetching and backup can happen at a fraction of the power. Software that declines to use them is opting out of the main reason the chip is arranged this way.
Reading a benchmark with this in mind
Two habits follow from the measurement.
A single-core score depends entirely on which core type the work landed on, so comparing two single-core figures across machines is only meaningful if both ran at the same class. The score alone does not tell you, and a benchmark that does not declare its QoS is not reporting a property of the chip.
A multi-core score cannot be divided by the core count to get a per-core figure. On this machine four of the fourteen cores have half the L1 data cache and a quarter of the shared L2, and they contribute proportionally less. The average of ten large cores and four small ones describes no core that exists.
Takeaways
- The identical binary took 0.865 s at default QoS and 2.61 s under taskpolicy -b, a factor of 3.02.
- Nothing about the code changed. Only the quality-of-service class attached to the process differed.
- macOS exposes no way to pin a thread to a specific core; QoS is the only lever available.
- Background QoS timings varied far more than default ones, ranging 2.35 s to 2.81 s against 0.86 s to 0.87 s.
Questions
- Can I force my program onto the performance cores?
- No. There is no affinity API on Apple Silicon that pins a thread to a core. You declare intent through a quality-of-service class and the scheduler decides. taskpolicy can push work down, but nothing reliably pushes it up beyond running at a normal or user-interactive class.
- Is background QoS a bug to be avoided?
- It is the correct choice for work nobody is waiting on, such as indexing, backups or prefetching. Efficiency cores use a fraction of the power. The problem is only ever applying it to work that someone is waiting for.
- Why was the first default run slower than the next two?
- Cold start: the binary was not in the page cache and the branch predictors had no history. That is why the article reports all six timings and takes the mean of the warm runs rather than quietly discarding the outlier.
Sources
What we read. Distinct from what we measured, which is in the article itself.