TechX

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.

Tested on MacBook Pro (Mac16,8) · Apple M4 Pro · macOS 26.3.1 (25D771280a) · July 2026

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.

3.02×
QoS penalty n=6 Mean background time over mean warm default time, identical binary

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.

Default, cold run 1 1.40 s Default, warm runs 2-3 mean 0.865 s Background runs 1-3 mean 2.61 s
Every run, in order. Default QoS converges tightly after the cold first run; background QoS never settles, because work in that band is interruptible by design.

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.

0.865 s
Default QoS n=3 ±0.005 s Mean of warm runs 2 and 3, single-threaded loop

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.

About the author

Teja Pagidimarri

11+ years in content marketing and SEO, based in Hyderabad, India. Every measurement published on TechX was taken by hand on the hardware listed in how we test.