Skip to content

GPU voltage-frequency curve control without Afterburner

vfctl is a Go CLI that reads and writes NVIDIA GPU voltage-frequency curves directly via NVAPI, replacing MSI Afterburner’s curve editor. Built after Afterburner’s editor repeatedly produced broken curves - notch artifacts at selection boundaries, temperature-stale baselines, and unexpected whole-curve shifts1 - on an RTX 5090 FE.

Curve struct layouts are from the PenguinBurner reverse-engineering effort2, cross-checked against the LACT issue #936 discovery3 and verified via ctypes. Telemetry structs are from nvapi-sys4 and NVIDIA’s official open-source NVAPI reference5. The write path is verified end-to-end on live silicon: a single-point selftest writes +15 MHz, reads it back, and restores. Telemetry (clock, voltage, utilisation, temperature, power) is verified across full gaming sessions. Measured on a single RTX 5090 FE with driver 610.88; the curve interfaces are undocumented and could change between driver releases.

  • Curve write, telemetry, persistence and automated stability search in one static binary, replacing Afterburner for undervolting.
  • Writes are read-modify-write via ClkVfPointsSetControl, confirmed by selftest on the card.
  • Telemetry covers clock, voltage, utilisation, temperature and live power draw, logged as CSV with a voltage histogram summary.
  • A logon task runs vfctl hold, which applies the curve and re-applies it whenever a driver reset wipes the offsets.
  • On Blackwell the curve sets the frequency ceiling per voltage, and the DVFS governor picks where the card sits.
PackageWhat it ownsKey detail
internal/vfcurveAfterburner blob parse/encode, generation, validation12-byte header, 127 points
internal/curveLive-curve math: BuildOffsets, VerifyOffsets, ApplyCurvebounds guard, read-back verify
internal/testloopTDR-loop logic: Classify, StepFreq, ParseTDRCount100% coverage
internal/profileAfterburner .cfg INI read/writelegacy import path
internal/taskTask Scheduler XML for persistno 72h limit, restart on failure
internal/nvapinvapi64.dll syscall layercurve writes + telemetry reads
cmd/vfctlCLI dispatch16 commands

Built partly by a self-correcting loop on a local Gemma 4 26B model - the internal/curve and internal/testloop extractions converged in one iteration each against a go test sensor, with human review catching a dropped sentinel guard the tests missed.

Three bugs separated the first attempt from a working write, and each was caught only by a stricter verification step than the last:

  1. A 4-byte struct offset. The control struct’s points start at 0x44; the 0x48 came from a read-only PoC that never exercised a write.
  2. Mixed struct generations. The status read used a 128-point layout while the control write used 255 points - different index spaces, so “point 72” in the read wasn’t “point 72” in the write.
  3. A fresh buffer instead of read-modify-write. The driver silently ignores writes to points whose type_ field doesn’t match what it returned. A zeroed buffer writes nothing that sticks.

Writes land when GetControl fills the full table (preserving type_/rsvd), one freq_offset_kHz is modified, and SetControl writes the whole table back. vfctl then reads the point back and treats a write that does not read back as an error.

A fourth bug was in the read path. The status struct’s freq_kHz (+4) is the nominal frequency (base + applied offset), not the base. The true base is vf_tuple_base.freq_kHz (+12). Using the nominal double-counts offsets on any non-stock card. This is why Afterburner profiles “mutate” after apply - the stored frequencies are a temperature-time snapshot, and only the offsets are real.

watch samples clock, voltage, utilisation, temperature, and power every interval, logs CSV, and prints a per-voltage histogram on exit. Full-session data from The Finals on an undervolted 5090:

MetricMenuFull load
Core clock2415 MHz2433 avg / 2497 max
Voltage880-885 mV930-935 mV (95.7% of time)
Utilisation13-17%72% avg / 99% peaks
Temperature45C57 avg / 61 max
Power31W76W avg / 103W max

The card sits at 930 mV with the curve targeting 860 because Blackwell’s DVFS governor adds voltage margin under sustained load, independent of the curve. Power (103W max of 575W) and temperature (61C) ruled out both limiters. The voltage step happens the instant utilisation crosses ~85%, before power or clock change, so it tracks how long the load has run rather than how much it asks for.

The cost of that margin is ~5W. The heat goal (“stock perf or better, lowest possible”) was already met: 2433 MHz avg at 76W average on a 575W card.

All accessed via nvapi_QueryInterface(function_id) on nvapi64.dll. Version field: (version << 16) | struct_size.

IDNameStructNotes
0x507B4B59ClkVfPointsGetInfoInfoV1, 0x182Cactive mask + per-point type/voltage_based
0x21537AD4ClkVfPointsGetStatusStatusV3, 0x15B0C255 pts x 348B at 0x68; base at +12, nominal at +4
0x23F1B133ClkVfPointsGetControlControlV1, 0x2420255 pts x 36B at 0x44; offset at +20
0x0733E009ClkVfPointsSetControlControlV1, 0x2420read-modify-write ONLY

Only points with info-struct type==0 && voltage_based==1 are editable core points - the 255-point buffer also carries memory/other clock domains, and writing those offsets corrupts them.

IDNameReads
0xDCB616C3GetAllClockFrequenciescurrent core/memory clocks (kHz)
0x60DED2EDGetDynamicPstatesInfoExutilization % (domain 0)
0xE3640A56GetThermalSettingstemperature - 3-arg call (gpu, sensorIndex, struct)
0xEDCF624EClientPowerTopologyGetStatuslive power draw
0x465F9BCFClientVoltRailsGetStatusvoltage (uV at +0x28)

ClientPowerPoliciesGetStatus (0x70916171) is one name away from ClientPowerTopologyGetStatus and returns the power-limit policy (frozen ~100%), not live draw. The symptom is a power reading pinned at exactly 100.0 W that never moves.

Stock below the ramp start, linear ramp to the target frequency at the target voltage, flat above. Offsets are computed against the live base (vf_tuple_base), so the result is temperature-correct at apply time:

offset = target_freq - base_freq_at_voltage

NVIDIA snaps frequencies to 15 MHz steps. A bounds guard rejects values outside sane envelopes (the catastrophic-typo check: 27970 instead of 2797). Every write is verified by read-back within one step, and set self-cleans stale offsets below the ramp region so switching curves never stacks.

Persistence: offsets do not survive a driver reload

Section titled “Persistence: offsets do not survive a driver reload”

The offsets live in driver memory. A reboot clears them, and so does any driver reload - including a driver reinstall by the NVIDIA installer while the desktop is running. On 2026-10-08 an installer run reinstalled nvlddmkm mid-session and the card went back to the stock curve with nothing signalling the process that had set it; the original logon-only set task never noticed.

vfctl hold closes that gap. It applies the curve, then every 15 s opens a fresh NVAPI session, re-reads the curve, and re-applies when the offsets drift from what it last wrote. After 20 failed checks in a row it exits non-zero so Task Scheduler restarts it with a freshly loaded nvapi64.dll. persist installs the binary under %ProgramFiles% (the task runs elevated, so its executable must not sit in a user-writable folder) and registers the task without the default 72-hour execution limit. Verified live: resetting the card to stock while hold ran was undone within one check.

  1. Run the selftest first: write +15 MHz to one point, read back, restore. This proves the write path before anything else depends on it.
  2. Test in a game: The Finals (Unreal Engine 5, transient-heavy).
  3. On TDR (Windows Event 4101), step down -15 MHz and retest; vfctl test automates this loop.
  4. Watch the histogram, and judge the undervolt by where the card sits under load rather than by the target you set.

On driver 610.88, the stability cliff for a 5090 FE is exactly one 15 MHz step wide: 2812 @ 900 mV is stable, 2827 (the old-driver stable curve) crashes.

  • Writes require Administrator (error -137, NVAPI_INVALID_USER_PRIVILEGE); reads work unelevated
  • The curve does not pin the operating voltage; the DVFS governor adds margin under sustained load. Forcing lower needs a declining curve, which is notch territory
  • The undocumented curve interfaces may change between driver releases
  • Windows only today. The Linux driver ships libnvidia-api.so.1 with the same nvapi_QueryInterface entry point and VF function IDs (LACT’s VF curve editor uses them6), so a Linux backend is a port of the call layer, not new reverse engineering
  • Fan control is out of scope: manual % clamps to 30% minimum; 0 RPM is vBIOS-auto only. FanControl handles it
  • Tested on a single card: RTX 5090 FE (Blackwell GB202)
  1. st4rdog, “MSI Afterburner curve editor shortcuts,” GitHub Gist. https://gist.github.com/st4rdog/d305609977037e64684a7932609446de ↩

  2. jpietek, “hidden_nvapi_vf.py,” PenguinBurner. https://github.com/jpietek/PenguinBurner/blob/d8901e64a9c7b479d6ae4ced63b7a2048b61e3ce/hidden_nvapi_vf.py ↩

  3. Loong0x00, “Feature: Per-point V/F curve reading and editing for NVIDIA GPUs via undocumented NvAPI,” LACT issue #936. https://github.com/ilya-zlobintsev/LACT/issues/936 ↩

  4. arcnmx, “nvapi-sys,” Rust crate. https://docs.rs/nvapi-sys - struct layouts for power/thermal reads ↩

  5. NVIDIA, “NVAPI Reference Documentation,” Release 590. https://docs.nvidia.com/nvapi - documented clock/thermal APIs ↩

  6. I. Zlobintsev, “feat: add Nvidia VF curve editor,” LACT pull request #957, merged 2026-04-18. https://github.com/ilya-zlobintsev/LACT/pull/957 ↩