Hardware August 2026

macOS clamps my M3 Ultra's GPU to 338 MHz before the fans even try. Maxing them doubled my LLM throughput.

Two days of benchmarks kept lying to me. The chip was fine. The firmware was slamming the brakes with the cooling barely trying.

TL;DR

Under sustained LLM load, my Mac Studio M3 Ultra (512GB, macOS 26.3) holds full GPU speed for about three minutes at roughly 141 to 151 W, then a firmware limiter steps the clock down and clamps it at 338 MHz drawing under 5 W, and holds that clamp for more than eight minutes even as the die cools into the mid-50s C. The fans never pass 70% of their maximum through any of it. Identical benchmark runs go from 27.5 to 2.2 tokens/sec with nothing in pmset ever reporting a thermal event.

Die temperature turns out to be an input to that limiter. Pinning both fans at max with fanpro, an SMC fan tool I wrote, roughly doubles the full-speed window (to about five and a half minutes) and roughly doubles sustained throughput: 1.91x prefill, 2.57x decode over the stock fan curve. A 100,000-token-context job that took 568 to 648 seconds under stock fans now finishes in 259 seconds at 403 tok/s prefill and 28.35 tok/s decode, with byte-identical output. Recovery after the clamp takes 60 to 120 seconds of idle, not the 10 minutes I had been superstitiously waiting.

The benchmark that kept lying

I have been benchmarking DeepSeek-V4-Flash-0731 on this machine with the ds4 engine (a 91 GiB quant, Metal). The numbers refused to stay put: throughput would drift 2 to 4x within a session, and every A/B comparison needed paired, interleaved runs just to mean anything. So I ran the simplest possible experiment: six identical runs, back to back, each one a fresh process prefilling a ~31,000-token prompt and decoding 150 tokens, with per-second GPU telemetry recording the whole time.

Run Prefill tok/s Decode tok/s
1505.1027.47
2465.2127.66
388.882.21
484.9112.00
5163.6612.82
6166.8812.20

Same binary, same prompt, same everything. By run 3 the machine is at a fifth of its prefill speed and a twelfth of its decode speed, and then it partially recovers to roughly a third of its prefill speed and 45% of its decode speed and sits there. Nothing an application did caused this, and no OS counter admitted to it: no swap, no memory pressure, and pmset -g therm reported no thermal warning at any point.

Catching the governor in the act

macmon reads GPU frequency, power, temperature and fan speed once a second without sudo. Its trace across those six runs is the whole story:

# macmon 1 Hz samples during six identical benchmark runs (selected rows)
#  t      freq(MHz)  gpu(W)   die(C)  fans(rpm of 3625)
   1:00     1379     141.7    66.8      1028      <- full speed
   2:00     1379     146.3    79.8      1683      <- full speed, heating
   2:30     1216     103.4    81.4      2157      <- first step down
   4:00      617      23.1    75.7      2509      <- collapsing
   5:00      338       4.7    70.5      2508      <- clamped
  10:00      338       4.9    56.4      2497      <- STILL clamped, die is cool

Three things in that trace matter. First, the clock starts stepping down about 2.5 minutes into sustained load and is fully parked at 338 MHz, pulling under 5 W, by minute five; in benchmark terms, the first two runs (about three minutes) finish at full speed and run 3 collapses. Second, the clamp stays on for more than eight minutes while the die cools from 81 C to the mid-50s, so this is not classic thermal throttling reacting to a hot chip; it behaves like a power-integral budget with a slow release. Third, the part that actually stung: through the entire episode the fans never exceeded roughly 2,500 of their 3,625 rpm. The machine throttled itself to 2 tokens/sec rather than spin its own fans to maximum.

Two details confirmed the mechanism. During the clamped run, the engine believed the GPU was 94% busy (its command buffers occupied 395 of 418 wall seconds) while macmon's frequency-scaled utilization read 9% (raw active residency ~38%, at a quarter of the clock): command buffers queue up and wait while the GPU delivers a small fraction of its full-speed work, which is what a duty-cycle floor looks like from the inside. And a pair of microbenchmarks separated clock from memory: raw GEMM compute fell 4x in the clamped state (21.90 to 5.50 TFLOPS) while streaming bandwidth fell only 1.4x (630 to 449 GB/s). The core clock is what gets taken away.

This has prior art. llama.cpp issue #10444 documents the same signature on an M3 Max: identical consecutive runs decaying 8.6 to 2.4 tok/s at core temperatures of 60 to 70 C, Apple's own diagnostics finding no fault, and maintainers unable to reproduce it on M1, M2 Ultra or M4. The behavior reads as strongest on the M3 generation, though one commenter in the same thread reported GPU throttling on an M2 Max with large prompts, and the issue was eventually auto-closed as stale, unresolved. As background on where such machinery could live: the Asahi Linux kernel's device tree for the M1 GPU, with values mirrored from Apple's own, includes firmware-side average-power zones and minimum duty cycles. Those constants are M1, not M3, but they name the kind of mechanism that would produce exactly this trace.

Two half-fixes: fast recovery and a duty-cycle cap

Knowing the mechanism immediately paid for itself twice. First, recovery. I had been waiting 10 minutes between benchmark sessions because that reliably restored speed. Probing the clamped state with a 2-second GEMM every minute showed the limiter actually releases within 60 to 120 seconds of idle: 60 seconds after load stopped, compute was already back to 20.04 of the 21.90 TFLOPS baseline. My cooldowns had been 5x longer than necessary.

# The 2-second probe that tells you which state the GPU is in.
# Fresh reads ~21.9 TFLOPS on an M3 Ultra; clamped reads ~5.5.
python3 -c "
import mlx.core as mx, time
a = mx.random.normal((4096, 4096)); b = mx.random.normal((4096, 4096))
mx.eval(a, b); mx.eval(a @ b)
t = time.time()
for _ in range(40): mx.eval(a @ b)
print(f'{40*2*4096**3/(time.time()-t)/1e12:.2f} TFLOPS')"

Second, pacing. If the limiter meters a power budget, then never exceeding the budget should avoid the clamp entirely. The ds4 engine has a --power N flag that duty-cycles GPU command submission. Over a 20-minute saturation workload, capping at 70% delivered 14.7 tok/s sustained decode against 7.8 uncapped, a 1.88x win from asking for less. Capping harder at 55% was nearly flat (a 4 to 5% drift across 20 minutes, no collapse) but at a lower 12.1 tok/s. The output in every configuration was byte-identical to the uncapped runs; only the clock changes.

The fix the machine refused to apply itself

The trace kept pointing at the same absurdity: the firmware throttles partly on die temperature, and the fans, the one component whose job is die temperature, idle through the whole event. One reporter in the llama.cpp thread had raised sustained GPU power on an M3 Max with a literal external desk fan. So the experiment writes itself: pin the Mac Studio's own fans at maximum and rerun the exact six-run protocol.

macOS has no supported way to do that, so I used fanpro, a fan-control and thermal-monitoring tool I wrote for Apple Silicon: C, no dependencies beyond what macOS ships, talking to the SMC directly, with a root daemon, safety thresholds and a terminal dashboard. One honest embarrassment from the process: my first attempts wrote fan targets straight to the SMC and were silently reverted within a second, which looked exactly like firmware rejection. The culprit was my own fanprod daemon, already running in monitor mode and dutifully re-asserting the state it believed in. The moral is old: before blaming firmware, check which of your own daemons is holding the wheel.

Run Stock fans (prefill / decode) Fans at 3,625 rpm (prefill / decode)
1505.10 / 27.47504.36 / 27.47
2465.21 / 27.66504.53 / 27.51
388.88 / 2.21482.07 / 27.45
484.91 / 12.00401.59 / 26.12
5163.66 / 12.82245.74 / 12.03
6166.88 / 12.20125.54 / 14.60

Run 3, the run that collapsed to 2.21 tok/s on the stock fan curve, runs at full speed with the fans maxed. The full-speed window roughly doubles: two fast runs (about three minutes) became four (about five and a half), and across the fan session's six runs sustained throughput works out to 291.7 tok/s prefill (1.91x) and 20.1 tok/s decode (2.57x) against the stock session's 152.8 and 7.8. The limiter still exists; runs 5 and 6 show it arriving late and gentler. Cooling does not abolish the budget, it enlarges it. Both sessions started from a verified-fresh GPU (22.02 and 21.90 TFLOPS on the gate probe), and the fan session's outputs matched the stock session's byte for byte.

The payoff: 100k context without the tax

Here is why this matters beyond benchmark hygiene. A 100,000-token prefill takes minutes, which means every long-context job used to run head-first into the clamp mid-way, and the decode that followed happened at limiter speeds. I had spent days measuring "the cost of long context" on this machine. A large slice of that cost was never attention at all.

100k-context config (99,938-token prompt) Prefill tok/s Decode tok/s Whole job
Stock fans, uncapped (3 runs)163.0 to 185.98.82 to 10.58568 to 648 s
Stock fans, --power 70166.712.57624 s
Fans at max, uncapped403.4328.35259 s

With the fans pinned, the whole job fits inside the enlarged full-speed window: prefill runs at 403 tok/s and decode at 28.35, which is the same speed this model decodes at short context on a fresh GPU, with no sign of the clamp in the throughput. Every run in that table produced byte-identical output (the stock uncapped row even spans two engine builds that match byte for byte), so this is free speed in the strictest sense. The job is 2.2x faster than the best stock-fan run, and the "long context is slow here" number I had been quoting for this machine was substantially firmware policy dressed up as attention cost.

The recipe

# Before a big job (fanpro daemon running):
sudo fanpro mode curve       # let fanpro drive the fans
sudo fanpro set all 3625     # pin both fans at max

# ... run the job ...

sudo fanpro set all auto     # hand the fans back
# Manual control means the fan has left macOS thermal management.
# fanpro's daemon carries its own safety layer (panic-to-max on hot
# sensor classes, heartbeat watchdog, release on exit) and that layer
# is the only protection held fans have. Read its README before use.

My working policy now: for any GPU job under about five minutes, pre-spin the fans to max and run flat out (the 259-second 100k job above showed no sign of the clamp this way). For open-ended saturation, fans plus a duty-cycle cap. Between heavy jobs, 90 seconds of idle is a full reset. And brew install macmon earns a permanent place in the toolbox; you cannot see any of this in Activity Monitor.

Caveats, honestly

This is one machine: a Mac Studio M3 Ultra, 512GB, macOS 26.3, measured with DeepSeek-V4-Flash-0731 on the ds4 engine. The stock-versus-fans comparison is one session per arm, run on different days from a verified-fresh GPU, not an interleaved A/B/A/B; the byte-identical outputs and the dose-response inside each trace give me confidence in the direction and rough size of the effect, but the 1.91x and 2.57x deserve error bars I do not have. The llama.cpp thread suggests the behavior is strongest on the M3 generation; M1, M2 Ultra and M4 owners could not reproduce it there (one M2 Max commenter could), so your Mac may not have this problem. The limiter is real firmware policy, not a bug I can patch: even with the fans howling, relentless saturation eventually trips it. Manual fan control removes those fans from macOS's thermal management entirely; fanpro carries its own safety layer, but you should read what that means before running it. And the fans at 3,625 rpm are genuinely loud, which is the entire price of the 2.57x.

Data provenance

Every number above comes from runs on my machine recorded 4 to 6 August 2026: the six-run decay sessions (stock and fan-max) with per-second macmon JSONL traces, the recovery probes, the --power sweep, and the 100k-context runs with byte-diffed outputs. Engine-reported throughput comes from the runs' own logs; the 259-second figure is my driver's wall clock around the fan-max 100k run. Fresh-state gates for the compared sessions read 21.90 and 22.02 TFLOPS before the first run. Methodology quirks worth copying: every run is a fresh process, decode is measured by slope within a generation rather than wall-clock division, and any "identical output" claim was checked with a byte diff of non-empty files, never assumed.