Development
Build
dotnet build src -c Release
src/bin/simple-nvidia-undervolt.exe status
dotnet test tests # pure-logic unit tests (no GPU required)
dotnet test e2e # real GPU read/write tests; run as Administrator (see e2e/README.md)
The e2e project uses the real driver (writing tuning and reading it back). Its tests skip unless the
host is elevated with an NVIDIA GPU, and they restore the original tuning — see e2e/README.md.
For a standalone, single-file executable with no .NET runtime dependency, publish with Native AOT (needs
the Visual C++ build tools / vswhere.exe on PATH):
dotnet publish src -c Release
src/publish/simple-nvidia-undervolt.exe status
Profiles
The release’s profile shortcut tree is generated by profiles/generate.ps1 from
profiles/profiles.json — the source of truth for the values PROFILES.md documents. The publish
workflow builds it into the profiles zip; a local run writes to profiles/out.
Diagnostics
For inspecting the NVAPI tuning structs and confirming the layout on a given card. These are hidden from
the main --help, but are listed in --help-diagnostics.
curve Dump the live V/F curve.
layout Detect the status-curve offsets and check them against the build.
voltage Live core voltage/clock/temp/power, one-shot (continuous is 'watch').
clocks Current/base/boost clocks.
scan <value> Find a value across the tuning buffers.
snapshot / diff Capture buffers, then show which words a change moved.
probe <hexId> Find accepted (version, size) pairs for a function.
extent <hexId> <v> <sz> Measure the real struct size the driver writes.
raw <hexId> <v> <sz> [mask] Dump the 32-bit words a GET writes.
The curve lives in two index-aligned buffers: the status (0x21537AD4 v1, 28-byte entries, freq
+0x08 / volt +0x0C) holds the live effective curve; the control (0x23F1B133 v1, 36-byte entries,
signed kHz delta +0x18) holds the editable deltas. A control entry drives the next status anchor (so
anchor i ← control i-1). The status freq column can collapse at idle, but the voltages stay valid.
Requests for either buffer carry a bitmask (the words after the version) naming the point slots to
fill, and the driver fails the whole call with NVAPI_ERROR when the mask names a slot the card
doesn’t have. The card’s own mask comes from 0x507B4B59 (GetClockBoostMask, same 6188-byte struct
clkVfPointsInfo the scan dumps); the slot count varies by generation (~103 on a GTX 1080, 132 on a
5090), which is also why a raw dump of these buffers needs a maskWords argument small enough for
the card.
The core-domain point slots come first (a per-entry type word says which domain a slot belongs to: 0 = core, 1 = memory), and the memory slots follow — slot 80 on Pascal, slot 127 on a 5090.
Where the boost settles
A flatten pins the boost by curve shape alone, so the plan rests on where the boost algorithm settles. NVIDIA doesn’t document this; measured on an RTX 5090 under sustained load:
- One 5 mV step below the flat top’s first anchor — never on the flat itself. A flat from 910 mV runs the card at 905 mV even where no 905 mV anchor exists (Blackwell anchors cycle 5-5-5-10 mV): the driver’s curve is finer than the table, interpolating inside the gap.
- The realized clock tracks the middle of the cap→flat segment, within one ~7.5 MHz bin (read-backs step ~15 MHz on older generations). At a 900 mV cap: written 2880→2903 settles at 2887–2895; written 2872→2888 at 2880 exactly.
- The driver re-shapes written anchors by a bin or two (2872/2888 read back 2880/2887), so aiming finer than a bin is meaningless.
- A rise below ~2 bins folds: written 8 MHz apart, the pair snaps into one plateau and the settle drops 5 mV lower; 16 MHz holds. On generations with ~15 MHz bins, 16 MHz is one bin and may fold — degrading to less clock at less voltage (the safe direction), which the post-apply report flags when the rise reads back collapsed.
- A level flat can read back as a two-step staircase: every anchor rounds onto its own
~7.5 MHz clock bin, and a flat written near a bin edge can keep its first anchor(s) at the
written clock while the tail rounds a bin up (a 925 mV cap’s flat of 2498: 935/940 mV read
2497, 945 mV+ read 2505). The plateau pinning the boost is then the tail, and the settle
follows it up — 940 mV instead of the aimed 930. Which anchors round which way isn’t
predictable from the written value alone (the same 2498 rounds both ways within one write; a
2408 flat rounds up to 2415 uniformly), but at a fixed thermal state the rounding is
deterministic and readable back in milliseconds — so the apply probes nearby (cap, flat) pairs
against their read-backs until the realized settle is the plan’s (
CurveRefinement; one or two extra writes in the measured cases), keeping the closest attempt when nothing lands. - The effective curve steps with temperature, per anchor, deltas frozen: anchors cross individual thresholds where their realized clock moves one bin (measured: 885 mV at ~52 C, the 890-915 mV region at ~58-61 C, the 925-965 mV window stable to 61 C). A point landed at apply time holds only while the operating temperature stays on the same side of the nearby thresholds: at 60 C under load a cool-applied 925 mV cap held its point exactly, while a cool-applied 890 mV cap settled a step higher. Irreducible without probing at operating temperature — the status line reads the live slice and says where the boost sits now.
GpuTuning.BuildCurvePlan therefore writes the cap→flat segment as a straight line through
(settle voltage, requested clock) with a fixed 16 MHz rise: the smallest the driver keeps, and a
bound on the realized miss at half of it, with the post-write refinement absorbing the bin
rounding. On a 10 mV gap this operates the card 5 mV above the named cap (--mv 900 runs at
905 mV); keeping the voltage at or under the cap would cost a stock-slope step of clock, the
worse side of the trade.
Generations
Pascal (GTX 10xx, 2016) is the architectural floor: GPU Boost 3.0 introduced the per-point V/F
curve, and no ClkVfPoints interface exists before it — Maxwell and older cannot be tuned this way
at all (their undervolting is BIOS-mod territory). A failed VF-point read is diagnosed via the
public NvAPI_GPU_GetArchInfo (0xD8265D24): pre-Pascal architectures get a “cannot be tuned”
refusal by family name; Pascal-onward architectures get a stubbed-driver-interface message (seen on
some laptop SKUs). Volta (Titan V) is untested but has the Boost 3.0 curve; the mask adapts to
whatever slot layout it reports, while delta scaling stays off outside the Pascal band — a
doubled-unit Volta would land at half depth, reported as such.
The status curve’s frequencies are plain kHz on every generation. The control table’s delta field
is half-kHz units on Pascal (write
200000 to move an anchor 100 MHz) and plain kHz from Turing on. The unit is witnessed read-only via
0x64B43A6A (ClkDomainsGetInfo, the scan’s clkDomainsInfo): the graphics entry’s delta range
reads ±2,000,000 raw on Pascal and ±1,000,000 on plain-unit cards — both meaning the universal
±1000 MHz offset limit. NvApi converts at the field only on the exact Pascal signature and a
Pascal architecture id — the signature alone would also match a future plain-unit card that
genuinely widens its limit to ±2000 MHz, and doubling there overshoots — so the rest of the code
always works in true kHz.
Verify your card
Tuning sanity-checks the read path (the V/F curve must decode as a real table) and, right after writing, validates the written curve to confirm the change landed — reverting to stock if anything looks wrong. So confirm a card by trying a configuration:
simple-nvidia-undervolt tune --mv 950 --mhz 2800 --no-persist
# curve should be as applied - optionally use Afterburner to confirm
simple-nvidia-undervolt clear # back to stock
Reply here if successful so we mark the generation as supported. If it reverts instead, the offsets don’t match this card; use the advanced steps below.
Porting the write offsets to a new card
In an Administrator terminal:
- Run
clear, thensnapshot. - In Afterburner’s curve editor (Ctrl+F), raise one point by a clear amount (e.g. +100 MHz at 950 mV), flatten everything to its right, and click Apply.
- Run
diff. ThecurveControlrows it lists are the bytes your change moved: their spacing isCtrlEntryStrideand their position within an entry isCtrlDeltaOffset. - Open an issue with that
diffoutput and yourlayoutoutput. PinningCtrlEntryBasefrom it is the fiddly part, so put both in the bug report and we’ll compute the threeCtrl*values.