September 2026

By Davide Ciffa

Inference-aware cooling: 29% less fan speed on the AMD R9700, same temperatures

A fan controller only knows the past: the heat that has already happened. The AI model running on the card knows the future: how many tokens are queued, how long the prompt is, whether an agent is about to call again. On the AMD Radeon AI PRO R9700 we let the model tell the fan what is coming, so the fan spins for the work ahead instead of the heat behind. Chat runs with the blower at its quietest setting, agent loops at 29% less fan speed, batch work at 25% less, at the same peak temperatures and the same tokens per second. No power cap, no undervolt, no slower clocks.

An AMD Radeon AI PRO R9700 and a 120 mm case fan on a wooden deck under a starry sky
The R9700 blower is the loudest part of a Lucebox. The fix is not a bigger fan; it is telling the fan what the engine is about to do.
1,734 RPMchat: the blower never leaves its floor (stock firmware: 1,906, pulsing)
−29%agent loops: 2,343 vs 3,297 RPM at the same 88 C peak
−25%sustained batch: 2,686 vs 3,541 RPM, 90 vs 91 C peak

TL;DR

Why a fan controller is always late

The R9700 is a 300 W card with a 75 mm radial blower. Its firmware targets 80 C on the hotspot and pushes the fan to about 3,700 RPM whenever the die gets there. That policy is tuned for a server rack, and on a desk it produces the two sounds everyone knows: the pulse after every short request, when the fan ramps for heat that has already left, and the steady roar through an agent session, because the firmware cannot know that the pause between tool calls lasts eight seconds.

We recorded the stock behaviour on three synthetic traces that stand in for real use. Chat: two requests of 1,600 prompt tokens and 200 output tokens, then a 20 s pause. Agent: 30 s bursts of back-to-back requests with 8 s pauses. Sustained: back-to-back forever. Every run is six minutes, sampled at 1 Hz from the card's own sensors.

Blower RPM over six minutes of chat traffic Stock firmware pulses to about 2,245 RPM after each burst and settles near 1,900. The co-design stays flat at 1,734 RPM and never leaves its floor. 1,600 1,800 2,000 2,200 2,400 0 60 120 180 240 300 360 Seconds Blower RPM Stock firmware Co-design
Chat trace, blower RPM over six minutes. Stock firmware (grey) ramps 20 s after each burst, when the die is already back at 45 C. The co-design (gold) never moves.

The physics you cannot argue with

Before building anything, we measured what the fan actually buys. With the card pinned at 300 W we capped the blower at fixed speeds and watched the die settle.

Steady-state die temperature versus blower speed at 300 W At 300 W, 3,664 RPM holds 82 C, 2,821 RPM holds 88 C, and 2,376 RPM holds 93 C. Below about 2,400 RPM the die never settles: at 1,990 RPM it passed 100 C and the run was aborted after 70 seconds. 80 86 92 98 104 1,800 2,200 2,600 3,000 3,400 3,800 Blower RPM at 300 W Die temperature (C) firmware throttle our target never settles: 101 C in 70 s Steady state reached
Steady-state die temperature versus blower speed at 300 W, chassis fan on. Below about 2,400 RPM the die does not settle at all.

Two rules fall out of that curve. The first is a floor: under a sustained 300 W load, holding the die around 90 C needs 2,700 to 3,100 RPM, and nothing a controller does changes it. The second is why the floor is worth fighting for anyway: fan noise scales with roughly the fifth power of speed, so a 29% slower fan is about 7 dB quieter, half the perceived loudness.

ΔL ≈ 50 · log10(n1 / n0)  ⇒  50 · log10(2343 / 3297) = −7.4 dBFan law: acoustic power goes with n⁵, so every 26% of speed is worth 5 dB. The 1,750 RPM floor of this blower is inaudible at a desk; 2,900 is not.

The consequence for control is simple. Any second the fan spins faster than the die needs is wasted noise, and any second it spins slower than needed is a temperature overshoot the firmware will correct loudly. The only way to be right on both sides is to know what is coming.

Co-design: the engine forecasts its own heat

The inference engine has everything a forecast needs and a thermometer has none of it. So the server now publishes, once per second, a small JSON object: how many seconds of full-power work lie ahead.

known = prefill_left / rprefill + Σrequests max(L̂ − done, 50) / rdecode
busy_ahead = known + duty60s · max(0, 60 − known)prefill_left: prompt tokens still to compute, net of the prefix cache. rprefill, rdecode: the engine's own measured rates. L̂: the expected completion length, learned per request shape (thinking on/off, max_tokens bucket). duty60s: the busy fraction of the last minute, or an agent prior of 0.5 when the request carries tool definitions.

The first term is what the engine knows exactly: the request it is serving, its progress, and the queue behind it. The second term is a forecast of the session: an agent loop that kept the GPU busy 80% of the last minute will very likely keep doing so, and a request that arrives with tool definitions is an agent loop from its first token. A final answer without a tool call, or a client that disconnects, sets a session_over flag and the forecast drops to zero at once. Under concurrent serving the scheduler publishes the exact remaining work of every live slot. None of this reads a temperature: the engine stays portable, and the fan service owns the thermal side.

Forecast at request start versus the real remaining time Before the fixes, long outputs after the first request forecast 8.0 seconds against a real 27.0, and a 24K-token prompt forecast 1.4 seconds against a real 30.4. After the fixes both land within a second of the real value. Before the fixes After the fixes Real remaining time 0 7 14 21 28 35 s Long output, 1st request 1.2 s 5.5 s 26.7 s Long output, later ones 8.0 s 27.0 s 27.0 s RAG, 6K prompt 2.1 s 7.3 s 7.3 s Long context, 24K prompt 1.4 s 27.0 s 30.4 s
Forecast at request start versus the real remaining time. Two bugs the traces exposed: a single global completion length made 1,500-token answers look like 5 s, and the status phase flipped to decode before prefill, so 6K and 24K-token prompts forecast 2 s. Both fixed.

A thermal model at the sensor's noise floor

The forecast says how long the card will burn 300 W; the fan service has to say how hot the die gets, for a candidate fan speed, at the end of that horizon. We started with a hand-fitted two-node model, which is the physics: a fast hotspot term that follows power within seconds, and a slow heatsink node with a 45 s time constant.

Tdie(t) = Ths(t) + 30 · P / 300
dThs/dt = (Tss(P, n) − Ths) / τ,   τ ≈ 45 s
Tss(P, n) = 9 + 43 · (P / 300) · √(3660 / n)P in watts, n the blower speed in RPM. The square root is the empirical fit of thermal resistance against airflow for this blower and fin stack.

That model got the shape right and the number wrong: on held-out runs it under-predicted the hot samples by 4 to 6 C at the horizons the controller plans over, which is exactly how much the die overshot in the afternoon runs. So we replaced it with a learned one, a ridge regression on 25 features built from the current state, its 5 s and 10 s lags, the ambient proxies the card exposes, and the plan under evaluation.

H = wH · φ(x),   H ∈ {10, 30, 60} s
φ(x) = [1, Tdie, P, n, Tedge, Tmem, Tapu, lags, PH, nH, PH√(3660/nH), Pend, e−H/τTdie, prefill, …]PH and nH: mean power and fan speed over the horizon under the plan (300 W while busy, 20 W idle, the blower at the candidate floor). Pend: power at the horizon's end. prefill: the fraction of the horizon spent prefilling, because long prompts run hotter than decode at the same watts.
Mean absolute error of the die prediction on held-out hot samples The hand two-node model errs 3.2 to 6.5 C depending on the horizon. The learned 25-feature model errs 1.6 to 2.0 C, at the floor set by the sensor's own jitter of 2.4 C. 10 s ahead 30 s ahead 60 s ahead 0 1.4 2.8 4.2 5.6 7 C Hand two-node 3.2 5.6 6.5 Learned, 16 features 2.1 3.2 3.4 Learned, 25 features 1.6 1.8 2.0 Sensor jitter floor 2.4 2.4 2.4
Mean absolute error of the die prediction on held-out hot samples. The learned model sits at the floor set by the sensor itself: the junction reading jitters 3.4 C from one second to the next under a perfectly steady load.

The controller is then one line. Every second it searches the fan duty from 50% upward and keeps the first one whose predicted peak stays under the target at every horizon the forecast reaches:

floor = min { f : maxHH(f, plan) ≤ Tlim },   capped at the steady-state level + 3The floor is written as the first two points of the card's own fan curve; the curve's upper points (89 C 60%, 93 C 70%, 100 C 100%) stay in place as the reactive safety net, and the firmware throttles at 100 C regardless. The floor rises at once and fades 1% every 10 s when the forecast says idle, 1% every 3 s after session_over.

This is where the thermal mass gets used on purpose. A 30 s job from a cool die never reaches 90 C, so the fan stays at its floor and the job ends before the heat matters. A 60 s job does, so the floor goes straight to the level the end of the job needs, in one move, with no overshoot and no hunting. Long work settles at the physics floor.

Results

Everything below is the production service, the engine with the forecast, and a 92 C target, on one Lucebox with the R9700 and a Strix Halo, serving Qwen3.8-27B with its DFlash2 drafter. The target was chosen because it reproduces the stock firmware's peak temperatures.

Blower RPM over six minutes of agent traffic Stock firmware idles near 3,500 RPM through every pause. A temperature-only custom curve hunts between about 1,730 and 3,000 RPM every cycle. The co-design holds a steady band around 2,400 and fades out at the end. 1,600 2,100 2,600 3,100 3,600 0 60 120 180 240 300 360 Seconds Blower RPM Stock firmware Temperature-only curve Co-design
Agent trace. Stock (grey) idles at 3,500 RPM through every pause. A temperature-only custom curve (dim gold) is quieter on average but hunts 1,730 to 3,000 every cycle, which the ear hates most. The co-design (gold) holds a steady band and fades out at the end.
Mean blower RPM per workload, six-minute runs Stock firmware runs 1,906 to 3,541 RPM depending on the workload. The co-design runs 1,734 to 2,686 RPM at the same or a lower peak die temperature in every case with a stock comparison. Stock firmware Co-design Die peak 0 800 1600 2400 3200 4000 rpm Chat 1,906 76 C 1,734 73 C Agent loop 3,297 88 C 2,343 88 C Sustained 3,541 91 C 2,686 90 C Long outputs 2,595 90 C RAG 1,852 86 C Long context 2,584 93 C
Mean blower speed per workload, six-minute runs, identical throughput in every pair. Labels give the peak die temperature.
Six-minute runs on the same box, same model, same requests; stock firmware curve versus the co-design at a 92 C target
WorkloadStock blowerCo-design blower, mean / peakDie peakRequests, tok/s
Chat, 5 s bursts / 20 s pauses1,906 RPM, pulsing1,734 / 1,741, never leaves the floor73 C (stock 76)28, 65.6 (same)
Agent loop, 30 s / 8 s3,297 RPM2,343 / 2,64788 C (stock 88)94, 65.1 (same)
Long outputs, 1,500-token answers2,595 / 3,04490 C11, 50.4
RAG, 6K-token prompts, unique1,852 / 2,07886 C20, 18.1
Long context, 24K-token prompts, unique2,584 / 3,03893 C8, 3.1
Sustained batch3,541 RPM2,686 / 3,03190 C (stock 91)117, 64.7 (same)
Sustained, 4 concurrent streams2,697 / 2,93592 C320, 177 aggregate

The die runs a couple of degrees warmer on average than under stock, 75 C against 74 on the agent trace, 84 against 81 sustained, because the controller lets the card use its thermal mass instead of blowing it down early. The peaks, which are what protect the silicon, are the same. Memory stayed at 76 to 80 C throughout.

The target is the knob, and it is honest now. With the learned model the controller lands within 2 C of what it promises, so the target becomes a real trade: about 100 RPM per degree on agent loads and 113 on sustained work. 89 C runs the die 3 C cooler than stock's peaks for 300 RPM more; 92 C reproduces stock's peaks. We ship 92.

What batching does to heat

Concurrency changes the efficiency side without touching the noise side. With four streams the card still sits at 300 W and the blower at the same speed, but it delivers 2.7 times the tokens.

Sustained load under the co-design, one versus four concurrent streams, six minutes each
StreamsRequestsAggregate tok/sEnergy per tokenBlower, mean / peakDie peak
1117654.5 J2,686 / 3,031 RPM90 C
43201771.6 J2,697 / 2,935 RPM92 C

For a given amount of work the box therefore spends 63% less time at full blow, and each token costs a third of the energy. Under a continuous multi-user load the steady noise is unchanged, and the forecast handles the concurrent case with the exact per-slot progress the scheduler publishes.

Long context, the honest trade

A 24K-token prompt is 30 s of prefill at 300 W from a cool die, about one thermal time constant. That is the one workload where the forecast and the physics collide: the engine now predicts the 30 s correctly, and the model knows prefill runs hotter than decode, so holding a hard 92 C through it costs 3,300 to 3,600 RPM during the prefill. The previous model ran 400 RPM quieter by silently letting the peak reach 93 or 94 C. Which one a box should do is a config line, and for most clients the second is the right answer.

It has to fail safe

A fan controller that can be wrong about the future needs a floor it cannot fall through. The curve's upper points and the firmware's 100 C throttle are always there; on top, the service forces 100% fan if the die sits at 97 C, resets the curve on every exit, and falls back to the hand-fitted physics model with an extra 5 C of margin if its learned model is missing. We injected six faults under a sustained 300 W load:

Fault injection, production service, sustained load; the die never exceeded 90 C
FaultBehaviour
Hint file corrupted for 20 sread as no hint, floor faded one step, die 88 C
Hint stale for 40 sreactive fallback, floor re-planned the moment the hint returned
Engine killed, restarted after 25 sservice kept running, faded the floor, re-planned seconds after the load resumed
Service killed under loadcurve reset instantly, firmware took over at 3,549 RPM, restart re-applied a 61% floor within 20 s
Started with no model filebuilt-in physics model with margin
Reset command afterwardsidempotent, curve at firmware default

The same service was then run on the production STHT1 board with the DeepSeek V4 Flash hybrid stack on the R9700 and the Strix Halo: fan interface present, zero GPU errors, the curve reset on exit.

Hardware and setup

BoxLucebox: AMD Radeon AI PRO R9700 32 GB + Ryzen AI MAX+ 395 (Strix Halo), Ubuntu, kernel 7.0 amdgpu, ROCm 7.2
Fan interfaceamdgpu.ppfeaturemask with the overdrive bit set exposes gpu_od/fan_ctrl: a 5-point hotspot curve (50 to 100%), acoustic targets, minimum duty
Model under loadQwen3.8-27B UD-IQ4_XS with the DFlash2 drafter, 300 W pinned in both prefill and decode
Engine hintGET /v1/lucebox/thermal, or --thermal-hint-file written atomically every second
Thermal modelridge regression, 25 features, 3 horizons, fitted on 64 recorded runs (22,738 one-second rows); refit nightly on the box from its own logs
Chassisa 120 mm fan on the card's shroud at 100%: worth 4 C on memory and nothing on the die; the blower remains the only thing that moves air through the fin stack

Reproduce it

Everything lives in lucebox_cooling/fan_forecast: the fan service with its unit tests, the dataset builder and the model fit, the load traces, every recorded run, and the fault-injection script. make test, make dataset, make fit. Deploy the harness to a box, run a trace under the stock and lucefan policies, and the same report script prints the table above.

Bottom line

A fan does not need to be smarter; it needs to be told. The inference engine already knows the next minute of its own heat, and a 25-weight model turns that into the slowest fan speed the physics allows. On the R9700 that is silence for chat, a third less fan for agent work and a quarter less for batch work, at the same peak temperatures and the same tokens per second. Below that, only a bigger fan or a passive card goes.


All numbers measured by us on one Lucebox (R9700 + Strix Halo) on September 15 to 17, 2026, at 1 Hz from the card's own sensors, six minutes per run, identical request traces for every pair. Run logs, the dataset and the scripts are in the lucebox_cooling module. Fan noise is quoted in RPM; the fan-law conversion to dB is the standard n⁵ rule, not a microphone measurement.

Related

A quiet box that runs Qwen3.8-27B at 200+ tok/s

Open-source engine. The fan follows the engine, not the thermometer.

GitHub R9700 post Discord