Fan curve and thermal soak tooling for NVIDIA Jetson.
Three things this does that nano /etc/nvfancontrol.conf does not:
- Speaks in degrees Celsius. The stock fan config does not, and gets edited backwards constantly. See the trap.
- Detects your board at runtime. No hardcoded hwmon indices, thermal zone numbers, or config paths — those differ per module and can move between boots.
- Tells you whether your temperature actually converged, instead of just reporting a peak.
Everything runs on stock JetPack. No dependencies beyond bash, python3, and (for the GPU load) the CUDA toolkit that JetPack already installs.
git clone https://github.com/Hi5808/jetson-tools.git
cd jetson-tools
./jetson-fan-curve.sh --show # safe, read-only: what is my board doing?That prints your detected hardware, your current curve translated into real temperatures, and live fan state. Start here — it changes nothing, and on an unfamiliar board it tells you whether the rest of this will behave.
Then, to make the fan more aggressive:
sudo ./jetson-fan-curve.sh --target 70 # full fan by 70 C, tapering belowTo find out what your cooling actually sustains:
./build-gpu-burn.sh # needs JetPack's CUDA toolkit
./jetson-soak.sh --load 1800 # 30 minute soak, writes a CSVTo undo anything:
sudo ./jetson-fan-curve.sh --restoreOpen /etc/nvfancontrol.conf on an Orin and you will find something like this:
TMARGIN ENABLED
FAN_PROFILE quiet {
#TEMP HYST PWM RPM
0 0 255 6000
11 0 187 4000
31 0 187 4000
70 0 0 0
105 0 0 0
}
Read as temperature, that says full fan at 0 °C, fan off at 70–105 °C. Nobody designed that.
TMARGIN ENABLED makes column 1 thermal margin below the limit, not degrees:
Tj = GROUP_MAX_TEMP - TEMP_column # 105 - 70 = fan off below 35 C
So if you want the fan at full speed by 60 °C and you write 60, you have
actually set 45 °C — and every edit after that is reasoned about backwards.
The correct value is 105 - 60 = 45.
This is why the tool exists. jetson-fan-curve.sh always takes and prints real
temperatures and does the conversion internally. If your board doesn't use
TMARGIN, it detects that too and writes absolute degrees instead.
Verify it yourself on a running board — three checks that should agree:
# 1. Kernel trip points are the Celsius complements of the curve breakpoints
cat /sys/devices/virtual/thermal/thermal_zone*/trip_point_*_temp
# 35000 -> margin 70 ... 74000 -> margin 31 ... 95000 -> margin 10
# 2. The Celsius reading implies zero fan above the critical trip. Absurd.
# 3. Predict RPM from the curve and compare to the tachometer.
# (Avoid sampling at exactly limit/2 - both readings agree there by
# coincidence and the test tells you nothing.)./jetson-fan-curve.sh --show # read-only
sudo ./jetson-fan-curve.sh --target 70 # full fan by Tj 70 C
sudo ./jetson-fan-curve.sh --max # pin flat out (test mode)
sudo ./jetson-fan-curve.sh --restore # revert newest backupEvery write backs up the config first, timestamped. --profile cool targets the
other stock profile. JT_RPM_MAX=7000 overrides the detected fan ceiling.
Always sanity-check the direction after a change: fan RPM must rise as Tj
rises. If it falls, the encoding on your board is the opposite of what was
detected — --restore immediately and open an issue.
--max sets an RPM target above the declared ceiling. Because the controller
chases RPM rather than PWM, an unreachable target drives PWM to 255 and reveals
the fan's real maximum. On the test board this gave 6258–6296 RPM against a
declared ceiling of 6000 — the stock profile leaves ~4–5% of airflow on the
table. It also means "the fan is maxed" should be judged from PWM, not RPM.
Note the ceiling is detected from the oldest backup, not the live config,
precisely because --max writes an inflated number that would otherwise
compound on every subsequent run.
./jetson-soak.sh --load 1800 # 30 min
./jetson-soak.sh --load 600 --no-gpu # CPU only
./jetson-soak.sh --load 900 --gpu-mb 2048 # bigger DRAM working set
./jetson-soak.sh --load 900 --expect-cpu-mhz 1700Runs unprivileged. Logs Tj, CPU/GPU temps, fan RPM and PWM, CPU and GPU clocks, and three INA3221 power rails to CSV, then reports:
load samples : 177
peak Tj : 67 C
mean Tj : 64.6 C
peak fan : 6296 RPM
mean VDD_IN : 20.0 W
cpu clk : 1728-1728 MHz
throttled : no
final-third drift: +0.077 C/min (from millidegree samples)
VERDICT: CONVERGED - thermally stable at this load
The drift line is the point. A peak temperature can't distinguish "hot but stable" from "still climbing when the test ended," and those mean opposite things for a 24/7 deployment.
The regression runs on millidegrees, not whole degrees. At 1 °C resolution a
single rounding step across a 7-minute window reads as ~0.2 °C/min of trend —
enough to flip a converged soak to "still drifting" on noise alone. Both columns
are logged; use tj_mc for analysis.
Caveats on the verdict: the thresholds are heuristics, and no ambient sensor is involved, so a slow positive slope may be your room warming rather than the board failing to settle.
nvpmodel -m <n> can return success, log as applied, and silently revert —
MAXN modes generally need a reboot. A harness that records the requested mode
will happily produce a complete, plausible dataset labelled MAXN_SUPER that
actually describes the old power cap. This happened during development and the
data looked entirely reasonable.
--expect-cpu-mhz gates collection on the achieved scaling_max_freq and exits
non-zero without writing a CSV if it doesn't match. Use it in any automated
power-mode comparison.
./build-gpu-burn.sh # builds for your compute capability
./gpu_burn 300 # 5 min, 512 MB working set
./gpu_burn 300 2048 # 2 GB working set
GPU_BURN_MB=1024 ./gpu_burn 300Three concurrent streams — tensor-core HMMA, DRAM streaming, FP32 FMA — because SM occupancy alone does not reach the module's power envelope. Measured on the test board, same hardware and power mode:
| Load | VDD_IN | SOC rail |
|---|---|---|
| Idle | 5.5 W | 1.5 W |
| FP32 FMA only | 11.7 W | 1.4 W |
| Tensor + DRAM + FP32 | 16.2 W | 5.1 W |
+ CPU workers + jetson_clocks |
20.0 W | 5.2 W |
The SOC rail is the tell: a register-resident FP32 kernel that fully occupies every SM moves it not at all, because it generates zero DRAM traffic. Thermal headroom measured that way is optimistic against real inference, which streams weights from memory and uses tensor cores.
The working set must exceed L2 (2 MB on Orin) or you are benchmarking cache. It is clamped to 80% of free memory — Jetson memory is unified, so an oversized buffer starves the OS rather than failing cleanly.
jetson_clocks pins CPU/GPU/EMC to maximum and disables DVFS — and does not
survive a reboot. This installs a systemd oneshot that re-applies it at boot:
sudo ./install-jetson-clocks-service.sh # only in MAXN_SUPER (id 2)
sudo ./install-jetson-clocks-service.sh --mode 1 # only in mode id 1
sudo ./install-jetson-clocks-service.sh --any-mode # unconditionally
sudo ./install-jetson-clocks-service.sh --uninstallIt is gated on the power mode by default. Pinning clocks to maximum while the board sits in a 15 W mode is rarely intended, and doing it silently makes later power-mode changes behave confusingly.
Two implementation details that matter if you write your own version:
nvpmodel applies asynchronously, so the unit runs After=nvpmodel.service
and waits before reading the mode — read it too early and the guard tests a
stale value and skips on every boot. And note that systemctl enable alone
proves nothing: verify with systemctl start and then an actual reboot.
Verify after reboot:
systemctl status jetson-clocks.service
# Active: active (exited)
# applied jetson_clocks in NV Power Mode: MAXN_SUPER (cpu pinned 1728-1728 MHz)
# min == max means clocks are pinned:
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_{min,max}_freqSource it in your own scripts:
source ./jetson_thermal_lib.sh
jt_detect
echo "Tj $(jt_temp_c) C, fan $(jt_fan_rpm) RPM, $(jt_power_mw 1) mW"
echo "curve value for 65 C is $(jt_temp_to_curve 65)"Discovers thermal zones by type, fan and INA3221 hwmon by name, GPU devfreq
path, the nvfancontrol config via readlink, and both the thermal limit and
TMARGIN state from the config itself.
Do not trust the device-tree model string. It is free text set by the BSP
and can be flatly wrong — the development board reports itself as an
NVIDIA Jetson Orin NX Engineering Reference Developer Kit Super while actually
being an Orin Nano 8GB in a reComputer J3011:
grep TNSPEC /etc/nv_boot_control.conf # 3767-301-0003-...-recomputer-orin-j401
tr -d '\0' < /proc/device-tree/compatible # nvidia,p3767-0003 / nvidia,tegra234P3767-0003 is the real identifier. Similarly, nvfancontrol_p3767_0000.conf
serves the entire P3767 family, so the config filename is not a SKU either.
A common assumption worth killing: nvpmodel power modes and nvfancontrol
fan profiles have no relationship. /etc/nvpmodel.conf contains no fan
references at all. There is no "fan curve for 25 W mode" — the curve you set is
global and applies in every power mode.
The two FAN_PROFILE blocks (quiet, cool) are alternative fan policies
selected by FAN_DEFAULT_PROFILE, not per-power-mode bindings. Only one is live
at a time.
- Throttle trips are high. On Orin the active trip is ~95 °C, critical ~104.5 °C. 60–80 °C under load is normal and costs nothing. Tuning for a low ceiling buys quiet, not lifespan.
- A fan curve cannot beat physics. Once the fan saturates, equilibrium is set
by airflow and ambient, not the profile. Past that point the only levers are
the
nvpmodelpower cap, ambient, and the enclosure. - Results are carrier-specific. The carrier board owns the heatsink, fan and airflow path. Identical module + identical curve on a different carrier gives different absolute temperatures.
- Idle cost is permanent. A curve that drops idle temperature 7 °C may double idle fan speed, and you hear that continuously.
- Synthetic load is a worst case. Few real workloads hit tensor cores, DRAM and FP32 simultaneously. Inference pipelines with duty cycles run cooler.
Developed and verified on a Seeed Studio reComputer J3011 — Jetson Orin Nano 8GB (module P3767-0003) on a J401 carrier — running JetPack 7 / L4T R39.2, CUDA 13.2, in its stock closed aluminium enclosure with no external airflow. Note this is production edge hardware, not an NVIDIA developer kit; the carrier and its cooling assembly are different, which is exactly why the absolute temperatures quoted in this README are illustrative rather than targets for your board.
The detection layer targets generic Jetson sysfs and should work on Orin
NX/AGX and Xavier; gpu_burn.cu falls back to FP32 below sm_70 for Nano/TX2
class hardware. Those are untested — --show is read-only and is the right
way to check what a new board reports before changing anything. Reports from
other modules are welcome.
Boards without TMARGIN are handled (absolute degrees, ascending). Passive
boards with no fan are detected and reported rather than crashed on.
/etc/nvfancontrol.conf is a symlink into /etc/nvpower/nvfancontrol/.
sed -i on the symlink replaces it with a regular file; these scripts resolve
it with readlink -f first. The target is a stock NVIDIA file and a JetPack or
OTA update may overwrite it — your timestamped backups are the record.
nvfancontrol caches state in /var/lib/nvfancontrol/status; a curve change is
unreliable without clearing it and restarting the service. The scripts do this.
These tools drive hardware to its thermal limits on purpose. The SoC's own throttle and critical trips stay active and are never modified — but run soaks where you can hear and see the machine.
The methodology and full measurements are written up in Fan Curve Tuning on Jetson Orin: The Thermal-Margin Encoding Trap.
examples/ contains a real 30-minute maxed-configuration soak log for reference.
MIT — see LICENSE.