diff --git a/docs/development.md b/docs/development.md index 53c769b..eb9f491 100644 --- a/docs/development.md +++ b/docs/development.md @@ -140,6 +140,7 @@ The applied offset is always at least one step below the confirmed edge. Profile | A logon apply that ends cleanly is no strike | Strikes count logons that may have crashed the machine. An apply that was refused before any write, or failed and reset the card to stock, did not: the strike it recorded up front is taken back. Only a failed reset keeps it. Before 0.3.1 three refused logons read as `disabled after 3 crashes`. | | One NVIDIA GPU per PC; with more, no run starts and the logon apply applies nothing | The app takes the first NVIDIA card three times over, and each list has its own order: NVML device 0 (`kGpu`) for the power limit and the telemetry, NVAPI's first physical GPU for the offsets and the curve, and DXGI's first NVIDIA adapter for the stress load and the bandwidth measurement. With two NVIDIA cards the card that is tuned and the card that is tested can be different ones: the tuned card then idles while the other one works, every step reads as stable, and the run saves a tune that was never tested. The read-back does not see it, because each value is read back from the card it was written to. So Optimize, Undervolt, All in one and Retest stop before they touch the card, and `decide_boot` returns `SeveralGpus` at logon (before the strike), when NVML counts more than one device (`several_gpus` in `src/core/boot.hpp`). NVML counts NVIDIA devices only: an Intel or AMD integrated GPU next to one NVIDIA card counts 1 and changes nothing. A count that cannot be read does not refuse; the calls after it report that failure themselves. The window's **Apply saved tune** and `gao --apply` refuse the same way (`saved_tune_for` passes the count to `decide_boot`), and the tray watchdog does not put a tune back once NVML counts a second NVIDIA GPU (one plugged in after the tune was applied): it says so once in boot.log and in a balloon. **Not chosen:** matching the three lists to each other (DXGI by the adapter LUID, NVAPI by the PCI bus) so that two cards work. It changes `src/hw/` and needs a PC with two NVIDIA cards to check; it waits for someone with such a PC to ask. Not tested on such a PC; the count of 1 is seen on an RTX 5070 next to an AMD Radeon integrated GPU. | | An undervolt is a flat top on the curve, found like a core offset | The driver lets a program offset the frequency of each point of the voltage/frequency curve; voltages cannot be written. To run a clock on less voltage, a lower point (the anchor) is raised to that clock and no point above it runs more, so the card has no reason to ask for more voltage. The points below keep the built-in curve, so idle clocks stay as they are. No voltage lock is set: the lock call hard-froze the reference RTX 4070 in the Python version. The write (`apply_flat_top`) uses the units the reference card stores (kHz, one to one; hardware check 61) and estimates nothing. What protects a card that stores them differently is the order: the first round raises a point only half of the way, so a card that moves twice as far lands on the target and not above it, and a card that did not move the anchor by what was written (within one step of its clock grid) is refused before the full step; each later round adds a point's own remaining gap as read back, which also follows a curve that shifts with temperature; no offset is ever written beyond what the plan asks plus two grid steps; and a card that has not arrived after four rounds is refused and cleared. (A first version estimated the units from the read-back; a review showed that estimate could push the anchor far above the target, and it was removed before any write ran on hardware.) A reset to stock zeroes a shape on the curve first and judges it by reading back; a plain core offset, which is the same offset on every point, still goes with the offset reset alone. The search (`find_undervolt`) keeps the clock the card runs under load at stock and moves the anchor one point down at a time. What a candidate asks of the card is its raise, the anchor's frequency above its built-in one, which is a core offset seen at one point; so the core search's rules carry over unchanged: first failure ends the descent, 30 s confirm, the same margin on the raise, 300 s soak, journal entry (`"uv"`) before the write, any failure ends at stock. The core offset and the curve are one table (hardware check 61), so a profile will carry one or the other. First version: `gao --undervolt` only, not saved, not in the window; no write has been run on hardware yet (checks 62 to 65). The curve calls are undocumented NVAPI, as the offsets are; the write procedure and the search are this project's own, with Green Curve (aufkrawall/green-curve, MIT) read as a worked example of what goes wrong on real cards. | +| A GTX 10 card's curve is read in half kHz | The curve calls are not documented, and the unit of a point's frequency and of its offset is the card's: kHz from the RTX 20 series on (hardware check 61), half kHz on a GTX 10 card (Pascal), where 1873 MHz reads `3746000`. Read as kHz, that curve lies twice as high as the clock NVML reports, no point of it is at or below the clock the card ran, and every undervolt search on such a card ended before its first candidate with `the load clock is below the card's curve`; All in one has that step too. Nothing wrong was written: the search stops there. `Nvapi` now asks the driver for the card's architecture (`NvAPI_GPU_GetArchInfo`, a public call) and brings both fields to kHz where the buffers are read and written (`vf_units_per_khz`, `parse_vf_curve`, `put_vf_raw_offset`), so the search, the saved tune and the window count in kHz on every card. An architecture that cannot be read counts as kHz, as before. The half kHz are not this project's own measurement: they come from a GTX 1080 on driver 582.78 (1556.5 MHz at 850 mV read `3113000`; reported for simple-nvidia-undervolt) and from two projects that drive the same calls on such cards (nvapi-rs counts the curve in half kHz; green-curve halves both fields for this architecture and uses the buffer layout this project does). **Verified on a GTX 1080 Ti** (driver 582.78, hardware check 110 (b)): the converted curve tops out at 1999.5 MHz, the clock under load was about 1860 MHz, and the undervolt search ran through its candidates to a saved result. Its clock steps are 38/3 MHz, read out in half MHz as 12.5 or 13 MHz; `clock_grid_khz` takes 12667 kHz for such a curve. Before that it took the greatest common divisor, 0.5 MHz, so the two grid steps of drift allowed after the points above the anchor are lowered shrank to 1 MHz, and the 1080 Ti's 881 mV candidate was refused for reading one step (13 MHz) above its target. | | Dear ImGui on DX11 | One small binary with no runtime, and the D3D11 device is in the process anyway for the stress load. | | The applied values are the profile's main fields; the tune the run found sits beside them in `step_down` | A step down (`gao --step-down`, **Lower tune 5 %**) writes the lowered core, memory and flat-top values into `core_mhz`, `mem_mhz` and `undervolt`, and keeps the found tune in `step_down.found` with the step, `tested` and `failed_step`. An older build ignores the unknown object and applies the main fields, which are the safer values; with the step count alone and the found values in the main fields it would apply the more aggressive tune the user stepped away from. Everything else (apply at logon, the watchdog, `tune_applied`, the retest) follows the main fields without a special case. Step n is n x 5 % of the gain over stock, always counted from the found tune, rounded towards stock (core 15 MHz, memory 50 MHz, the top clock down to the card's clock grid, 15 MHz when the curve cannot be read, but never below the built-in curve: the raise stays 0 or more); the top's voltage, the power limit and the fan curve never change. At most 10 steps. A `step_down` that cannot be read whole counts as absent. | | A failed test of a step steps down by itself | A step is derived from a tested tune and applied without a test of its own; **Test this step (5 min)** runs the retest on it. When that fails, sending the user to a 40-minute run is the wrong answer to a stutter they were trying to fix cheaply, so the test goes on at the next step down, one five-minute test per step, and ends at the first step that holds (applied, saved, `tested`). `failed_step` records the largest step number that failed, and +5 % never goes back to it or above it; a step that later holds its test sets it to the step below (`retest_stepping`). At step 10, or at a step that changes nothing, the card goes to stock and the tune (`failed_its_test`: its step at or below `failed_step`) is neither applied at logon nor put back by the watchdog; `gao --retest` that ends there tells a running tray to stop keeping it (`TrayNotice::TuneFailedTest`). **−5 %** stays allowed from there while a step remains: the lower step is a new tune above `failed_step`. The same holds for the retest after a driver update. Abort puts back what was on the card before the test started (`tune_before_run` / `put_back_after_stop`, in the window and in `gao --retest`), except when the test had already stepped down: then `run_retest` puts the step under test back and saves it, and `put_back_after_stop` sees that the saved tune changed under the run (`put_back_target`) and puts back that saved step if it may be applied, never the earlier step, which failed. A step whose test ended in a driver reset (`DEVICE LOST`, `STALLED`, `NO TELEMETRY`) is followed by the optimize run's recovery before the next step is loaded: reconnect, stock, a 20 s rest, a fresh stress load, and a 5 s health probe at stock (up to three tries, measured against the stock score the run saved; a profile without a saved stock score counts as back when its health probe runs clean). Without it the next step was loaded on a card that was not back and failed within seconds: on the owner's RTX 5070 one forced reset cost two steps. A second reset in the same test, in a step or in the health probe, or a card that does not come back ends it at stock with "optimize again", as the run's reset budget does. Every failed step's verdict goes to `run.log` once: in the log when the next step is tested (or a stop ends it), in the result lines when it was the last test. A step change is a new tune for the safety nets: it clears `boot_strikes` and the watchdog's reset history. | diff --git a/docs/hardware-checks.md b/docs/hardware-checks.md index 747c89d..f7b49d7 100644 --- a/docs/hardware-checks.md +++ b/docs/hardware-checks.md @@ -17,6 +17,7 @@ Checks 46-52 run `--optimize` as well and need an elevated shell; check 45 compa Checks 54-56 exercise the window after its display device went away; 54 and 55 change no GPU setting, 56 does run a search (from the window, elevated), which writes clocks and the power limit. Checks 62-72 write the voltage/frequency curve (all but 61, which only reads it) and need an elevated shell; 68 forces two driver resets, 69 to 71 run the undervolt search from the window, and 72 compares two shapes of the flat top under load. Checks 73 to 76 are about an overclock and an undervolt together: 75 and 76 run All in one, about 35 minutes of full load each. Checks 17-22 cover persistence; 19 and 20 need a log-off and log-on. +Check 110 reads the curve on a GTX 10 card and has not been run: the project has none. Commands assume a Release build at `.\build\Release\gao.exe` from the repo root; adjust the path for a Debug build. @@ -137,6 +138,7 @@ root; adjust the path for a Debug build. | 107 | A driver install with the tray running: the app restarts for the new driver, and puts the tune back on the driver it was tested on | The tray app of this build running (elevated, Apply at logon on) with the saved tune applied; an older NVIDIA driver installed over the current one (`setup.exe -s -noreboot -noeula`), then the current one again; `gao --status`, `boot.log`, the Application log and `%LOCALAPPDATA%\CrashDumps` | yes (tray) | No crash; at each install `the NVIDIA driver was installed anew; restarting the app to load its libraries` and a new tray process with `--resume --driver-updated`; on the older driver the tune stays off and `gao --status` advises the retest; back on the tune's own driver the tune is applied again by itself, as at logon | **Pass** (2026-11-12 18:18-18:35, the RTX 5070, 621.14 -> 617.14 -> 621.14, the saved All in one tune tested on 621.14; driven by an elevated script). 0.4.9-rc1 (first commit of this fix): at 617.14 a new tray (`--resume --driver-updated --hidden`) at 18:20:22, `driver: 617.14 (CHANGED from 621.14 -- test the tune again ...)`, tune off; at 621.14 a new tray at 18:23:47; no Application errors and no crash dumps (before the fix the tray died with 0xc0000409 at this point, 2026-11-05). But back on 621.14 the tune stayed off: the new copy only kept watching a tune that was still on the card. Fixed (668256d): once the new driver answers and it is the tune's own, the tune is applied as at logon. Checked without a third driver install: `gao --reset`, then the tray started as `--resume --driver-updated --hidden`; 15 s later `applied all: 2932 MHz at 960 mV, ... core +270 MHz, mem +1850 MHz`, `undervolt: applied`, the strike cleared 2 minutes later. Not seen: the balloon (nobody watched the screen). | | 108 | The 0.4.9 release checks (issue #69) on the owner's RTX 5070: install, apply, UAC, updater, a driver-library fault, and stopping runs | The phases of issue #69, from no risk to most risk, driven by an elevated helper script with real clicks (UI Automation finds the tray icon and menu items by name; a message box is answered only after its class, title and process are checked); a copy of `gao.json` kept before each phase; `run.log`, `boot.log`, `journal.jsonl`, `gao --status`, `gao.json`, the System log (nvlddmkm, Display) and the Application log (1000/1001/1002) read after each test. Phase 5 makes the retest ask by setting the profile's `driver` in `gao.json` to `617.14` while 621.14 is installed, and sets it back afterwards | yes (most) | Phases 0-2: the installed copy, `--status`, `--busy`, the tray menu and every way to apply or revert behave; the UAC restart opens the Optimize page without starting a run. Phase 3: the updater fetches the latest release and replaces only the two programs; an elevated copy outside Program Files asks for a restart instead of restarting. Phase 4: after a fault in `nvml.dll` a copy that may restart does so with `--resume --after-fault`; an elevated copy outside Program Files closes and says so. Phase 5: every stop ends at stock and then puts back the tune that was on the card; the result page says so calmly; a stopped All in one run leaves `gao.json` as it was before the run (#76) | **Pass; two known points that do not block the release; four things not seen** (2026-11-12, 20:56-22:47, the RTX 5070, driver 621.14, saved tune `all: 2932 MHz at 960 mV, power 100 %, core +270 MHz, mem +1850 MHz`). Builds: phases 0-4 first on the build of PR #75 (511556e); after #76, #77 and #78 were merged, phases 0-2, the UAC test and phase 5 again on local builds of the release code (579ca4f, whose tree is that of main 17249b9, `gao --version` `0.4.9`), installed with `gao --boot on`. Phases 3 and 4 were not repeated: nothing in the updater or the fault handling changed between 511556e and 17249b9 (the diff is the run undo of #76, the tray icon of #77 and the version). **Phase 0:** `gao --boot on`; logon task `C:\Program Files\GpuAutoOptimizer\GpuAutoOptimizer.exe --tray`; `boot copy: up to date`; the tray at that path. **Phase 1:** Dashboard, About and the tray menu (`Open / Re-apply saved tune / Lower tune 5 % (to -5 %) / Raise tune 5 % / Revert to stock / Exit`) as expected; `gao --busy` with a video playing in Chrome: `other programs on the card: chrome (2 %, video)`; with nothing open: `none`. **Phase 2:** tray Revert -> `core +0 MHz, mem +0 MHz`, `undervolt: not applied (the curve is at stock)`; tray Re-apply -> `core +270 MHz, mem +1850 MHz`, `undervolt: applied`; both balloons shown; `gao --reset` then `gao --apply` -> `applied all: ...`, `OK`, the tune on; `gao --reset` then the window's **Apply saved tune** -> the tune on. **UAC:** a copy started without administrator rights (a scheduled task with RunLevel Limited), Optimize page, **Optimize GPU** -> the UAC prompt (the owner clicked Yes) -> a copy with `--open-optimize=5` opened on the Optimize page, marked Administrator, and started no run. **Phase 3:** a copy of the code numbered 0.4.7 in an empty folder: not elevated, `gao --update` fetched 0.4.8; of the zip's files (`gao.exe`, `GpuAutoOptimizer.exe`, `LICENSE`, `README.md`, `THIRD-PARTY-NOTICES.md`) only the two programs were unpacked (the old ones left as `.old-` files), and the SHA-256 of both programs matched the v0.4.8 release zip (`gao.exe match: True`, `GpuAutoOptimizer.exe match: True`); a second copy, elevated, updated from the window: 0.4.8 in place, the note `Close GPU Auto Optimizer and start it again to use it.`, and no restart. **Phase 4** (test patch `--inject-nvml-fault=S`, never committed): not elevated, `--inject-nvml-fault=8`: at 8 s a new copy `--resume --after-fault` replaced the first; elevated, outside Program Files, `--tray --inject-nvml-fault=10`: `boot.log` `a call into a driver library faulted: write to 0x8 at ...nvml.dll+0x4dd71...` and `a call into the NVIDIA driver library faulted; the app closed itself. Start it again`, the process gone at 12 s and no new one; the tune stayed on (`strikes: 0 of 3` once the tray was started again). **Phase 5, D (Exit during a retest):** tray **Exit** -> `An optimize run is in progress. Stop it and exit?`; **No**: the retest went on; **Exit** again, **Yes** at 21:57:38: `ABORTED score=6033 it/s ... (226 s)`, `RESULT: not applied -- aborted -- card at stock`, `stopped: the tune that was on the card before the run is back: all: ...`, the app exited, `gao --status` the tune applied, `fans: driver control`. **A (Abort during a retest, twice in one session):** Abort at 57 s and, in a second retest started straight after, at 13 s: each `RESULT: not applied -- aborted -- card at stock` then `stopped: the tune that was on the card before the run is back`; the page `Stopped / You stopped the test. / Your saved tune is on the card again, as it was before the run.`; the second retest had the **Abort run** button; `gao.json` byte for byte unchanged. **B (All in one, Abort in step 2):** started 22:03:37 from the window, no question about other programs (`other programs on the card: none`); step 2 at 22:08:48; Abort at 22:10:51 in the memory scan (`core +0 / mem +850`, the core search not yet begun): `stopped: aborted -- card restored to stock`, `RESULT: not applied -- aborted -- card at stock`, `stopped: the tune that was on the card before the run is back`; the page `Stopped / You stopped the run. / Your saved tune is on the card again, as it was before the run.`, no red fault; `gao.json` byte for byte as before the run; the journal closed the candidate (`{"id":406,"state":"complete","verdict":"ABORTED"}`); a minute later still applied. **C (All in one, Abort in step 3, the check of #76):** started 22:25:53; stock 5907 it/s at 2819 MHz, 70 C; step 2: memory scan passed +3000, read again from +2800, `bandwidth peak +2700, confirmed +2700, applying +1850`; core climbed to +450, **driver reset at +465** (22:38:07, `DEVICE LOST`; System log nvlddmkm 14 once and 153 several times, 22:38:06-22:38:12), `the driver was reset; reconnecting`, health check `STABLE`, `confirm core +390 / mem +1850: STABLE`, `applying +270`, soak `STABLE score=6424 it/s` - step 2 saved this overclock (by chance the same offsets as the old tune, with a new date); step 3: undervolt lowest stable 915 mV, confirmed 915 mV, `applying 965 mV`; Abort at 22:46:17 in that soak: `nothing of this run is kept: the saved tune is the one from before the run: all: 2932 MHz at 960 mV, power 100 %, core +270 MHz, mem +1850 MHz (driver 621.14, saved 2026-10-17 23:31)`, `RESULT: not applied -- aborted -- card at stock`, `driver resets during this run: 1`, `stopped: the tune that was on the card before the run is back`; **`gao.json` byte for byte the copy taken before the run**; the page `Stopped / You stopped the run. / Your saved tune is on the card again, as it was before the run.` with the yellow note about the driver reset; `gao --status` and a minute later: profile and driver match, the tune and the undervolt applied, the fan curve on, `strikes: 0 of 3`. No Application errors in any phase; no nvlddmkm or Display events except the planned reset in C; the desktop stayed usable. **Known, not blocking:** (1) the tray menu still opens while the Exit question is shown, so choosing Exit again stacks a second question; the app exits only once both are answered (seen in D: the run had stopped and the tune was back, but the app waited for the first box). (2) The first bandwidth measurement of C's memory scan did not settle (`bandwidth did not settle within 20 runs: 20 readings from 559.6 to 571.2 GB/s (spread 2.1 %)`); the scan went on and its later readings were steady at about 645 GB/s; a video was playing in Chrome. **Not seen:** (a) the `Stopping...` state: each stop took about a second (a picture 1 s after Abort in A showed the page's content area empty, one 2 s after Abort in B already showed `Stopped`); (b) the UAC restart carrying a non-default profile (Quiet was not selected before the click, so only the profile already selected went across); (c) the tray tooltip after the stops (the tune staying applied a minute later was read from `gao --status`); (d) phase 6, the optional full All in one run, was not done. | | 109 | The released 0.4.8, installed in Program Files, updates itself to the released 0.4.9 and keeps the saved tune, the fan curve and apply at logon | Elevated helper script, the owner's PC; a copy of `gao.json` kept. The tray app exited (tray menu **Exit**); the v0.4.8 release zip (its SHA-256 checked against the release's `.sha256`) unpacked into an empty folder; its `gao --boot on`; `GpuAutoOptimizer.exe --tray` started from Program Files; after the strike cleared, the window opened and **Update available** in the sidebar clicked. Then: the files in Program Files against the v0.4.9 release zip, the processes, the tray menu, `gao --status`, the logon task, `gao.json` against the copy, `boot.log` and the Application log | yes | 0.4.8 offers the update; the update replaces only `gao.exe` and `GpuAutoOptimizer.exe` with the release's own files and the copy in Program Files starts itself again; the saved tune, the fan curve, apply at logon and the logon task are as before | **Pass, with one thing not seen** (2026-11-12 23:27-23:33, the RTX 5070, driver 621.14, v0.4.9 published 22:20 UTC as the latest release; saved tune `all: 2932 MHz at 960 mV, power 100 %, core +270 MHz, mem +1850 MHz (driver 621.14, saved 2026-10-17 23:31)`, the same date in `gao --status` and in `gao.json` (`"saved_at": "2026-10-17 23:31"`)). **0.4.8 installed:** `gao.exe` and `GpuAutoOptimizer.exe` in Program Files equal to those of the v0.4.8 zip, `gao --version` `0.4.8`; the tray started with `--tray` applied the tune (`boot.log` 23:27 `applied all: ... strike 1 clears in 2 minutes`, 23:29 `ran 2 minutes without a crash; strike cleared`). **The update:** the 0.4.8 window showed **Update available**, and its tooltip named 0.4.9 as available (seen by the owner on the screen; the helper's picture was taken too soon after the pointer moved there); clicked at 23:30:36; within 2 s Program Files held 0.4.9 and a new process `GpuAutoOptimizer.exe --resume` ran, its Dashboard with the **-5 %** / **+5 %** buttons of 0.4.9; `gao.exe` and `GpuAutoOptimizer.exe` equal to those of the v0.4.9 release zip, `gao --version` `0.4.9`; in the folder besides them only `GpuAutoOptimizer.exe.old-19258468` (the 0.4.8 window's exe, which was running); the tray menu of the new copy read `Open / Re-apply saved tune / Lower tune 5 % (to -5 %) / Raise tune 5 % / Revert to stock / Exit`. **Kept:** `gao --status` `driver: 621.14 (matches)`, `applied: power 100 %, core +270 MHz, mem +1850 MHz`, `undervolt: applied`, `fan curve: on, driver below 50 C, 50 C 30 %, 70 C 35 %, 75 C 100 %`, `boot-apply: on (logon task registered)`, `boot copy: up to date`, `strikes: 0 of 3`; the task `BootApply` still `C:\Program Files\GpuAutoOptimizer\GpuAutoOptimizer.exe --tray`, highest rights, ready; **`gao.json` byte for byte the copy taken before 0.4.8 was installed**. No Application error of the app (one of SnippingTool at 23:21, not related). **Not seen:** a real Windows sign-in or restart after the update: the logon task is unchanged, but the copy that starts at logon has not been seen starting from it. | +| 110 | The curve is read in kHz on every card: unchanged from the RTX 20 series on, halved on a GTX 10 card | (a) On a card of the RTX 20 series or newer, with a saved tune applied: `gao --curve` with this build and with the release before it. (b) On a GTX 10 card: `gao --curve` (elevated if it says so), `gao --stress 30` in a second window and `gao --probe` while it runs; then, elevated, `gao --undervolt` to the end, `gao --curve`, `gao --reset` | (a) no, (b) yes | (a) both builds print the same points, frequencies and raw offsets, and this build prints no line about half kHz: the architecture read changes nothing there. (b) `gao --curve` prints `the card counts its curve in half kHz`, and its top frequency is the clock `gao --probe` shows under load within a few steps (about 1900 MHz on a GTX 1080, not 3700); the undervolt run gets past `target: N MHz, which the card reaches at V mV by itself` to its candidates and does not end with `the load clock is below the card's curve`; after `gao --reset` every raw offset is 0. To be recorded, not assumed: the steps between the points of the curve, and whether a candidate is ever refused with a reason about the read-out | (a) **Pass** (2026-11-14, reference RTX 4070, driver 617.42, build 6a12a5a, elevated; the saved All in one tune applied: 2850 MHz at 1025 mV, core +135, mem +1400). `gao --curve` of this build, then of the v0.4.9 release zip (SHA-256 checked), then of this build again: three identical tables, 127 points from 450 to 1240 mV, top 2880.0 MHz, the same raw offsets (+135000 on the 92 points below the anchor, +165000 on the anchor at 1025 mV, down to -120000 at the top), steps between the points 15, 30 or 45 MHz; no line about half kHz. `gao --status` before and after: identical (`applied: power 105 %, core +135 MHz, mem +1400 MHz`, `undervolt: applied`). (b) **Pass for the units, by a user with v0.4.10-rc1** (2026-11-14, GTX 1080 Ti, driver 582.78, elevated; `gao --probe` not run, `gao --curve` after the run and `gao --reset` not reported). `gao --curve` printed `the card counts its curve in half kHz`, 80 points from 450 to 1243 mV, 227.5 to 1999.5 MHz, all raw offsets 0; the top lies about 8 % above the load clock (1860-1898 MHz at stock), not twice it. `gao --undervolt` got past `target: 1860 MHz, which the card reaches at 975 mV by itself` and ran its candidates: 962 to 893 mV STABLE, each at 1859-1864 MHz, so the written offsets (+12 to +113 MHz) landed where asked. The steps between the points read 12.5 or 13 MHz (38/3 MHz in half-MHz read-out). One candidate was refused with a reason about the read-out: `881 mV (+139 MHz): not set -- lowering the points above the anchor left 881 mV at 1873 MHz, the target is 1860 MHz`, one step off. `clock_grid_khz` had taken 0.5 MHz for this curve, so the drift allowed there was 9 MHz instead of 8 + 2 steps; fixed in #83 (12667 kHz for such a curve, a test with this curve; not yet run on a GTX 10 card). Result: 1860 MHz at 925 mV instead of 975 (lowest stable 893, confirmed). The stock measurement stopped at 86 C after 119 s (the card's own target is 84 C), so its before/after line compares 119 s at stock with 300 s on the profile's fan curve: before 2696 it/s, 178 W, fans 79 %; after 2610 it/s, 197 W, fans 89 %, peak 84 C. | ## Notes diff --git a/src/app/cli_hardware.cpp b/src/app/cli_hardware.cpp index 8cf545f..5e1368c 100644 --- a/src/app/cli_hardware.cpp +++ b/src/app/cli_hardware.cpp @@ -96,6 +96,8 @@ int curve() { gao::app::is_elevated() ? "" : "; reading it may need an administrator shell"); return 1; } + // Worth a line only where it differs: a GTX 10 card, see core/vf_curve.hpp. + if (nvapi.VfUnitsPerKhz(kGpu) != 1) std::printf("the card counts its curve in half kHz: converted, the numbers below are MHz and kHz\n"); print_curve(*points); return 0; } diff --git a/src/core/types.hpp b/src/core/types.hpp index 35d9e67..5d39348 100644 --- a/src/core/types.hpp +++ b/src/core/types.hpp @@ -35,7 +35,7 @@ struct VfPoint { int index = 0; // the slot in the driver's tables; what a write addresses int volt_uv = 0; int freq_khz = 0; // what the card runs at this voltage now: its built-in curve plus the offset - int raw_offset = 0; // the offset as the driver stores it (see core/vf_curve.hpp for the units) + int raw_offset = 0; // the offset the driver stores, in kHz (see core/vf_curve.hpp for the units) }; struct VfOffset { int index = 0; diff --git a/src/core/vf_curve.cpp b/src/core/vf_curve.cpp index 541e73e..b5d35af 100644 --- a/src/core/vf_curve.cpp +++ b/src/core/vf_curve.cpp @@ -34,7 +34,10 @@ bool at_stock(const std::vector& curve) { } -std::vector parse_vf_curve(const unsigned char* mask, const unsigned char* status, const unsigned char* control) { +int vf_units_per_khz(unsigned architecture) { return architecture == kVfArchPascal ? 2 : 1; } + +std::vector parse_vf_curve(const unsigned char* mask, const unsigned char* status, const unsigned char* control, + int units_per_khz) { std::vector points; for (int i = 0; i < kVfSlots; ++i) { const std::size_t slot = static_cast(i); @@ -45,9 +48,9 @@ std::vector parse_vf_curve(const unsigned char* mask, const unsigned ch if (get_u32(mask, m) != kVfDomainGraphics || mask[m + 4] == 0) continue; VfPoint p; p.index = i; - p.freq_khz = static_cast(get_u32(status, s + 4)); + p.freq_khz = static_cast(get_u32(status, s + 4) / static_cast(units_per_khz)); p.volt_uv = static_cast(get_u32(status, s + 8)); - p.raw_offset = get_i32(control, c + kVfControlOffset); + p.raw_offset = get_i32(control, c + kVfControlOffset) / units_per_khz; if (p.freq_khz <= 0 || p.volt_uv <= 0) continue; // an enabled slot the card does not use points.push_back(p); } @@ -55,7 +58,8 @@ std::vector parse_vf_curve(const unsigned char* mask, const unsigned ch return points; } -void put_vf_raw_offset(unsigned char* control, int index, int raw) { +void put_vf_raw_offset(unsigned char* control, int index, int raw, int units_per_khz) { + raw *= units_per_khz; std::memcpy(control + kVfEntries + static_cast(index) * kVfControlEntry + kVfControlOffset, &raw, sizeof(raw)); } @@ -109,6 +113,14 @@ int clock_grid_khz(const std::vector& curve) { if (!curve.empty()) for (int grid : {15000, 7500, 5000, 2500}) if (fits(grid)) return grid; + // A GTX 10 card steps in 38/3 MHz and reads out in half MHz, so its steps + // read 12.5 or 13 MHz: every step between two points is a whole number of + // 38/3 MHz to within half a MHz (counted in thirds of a kHz). + const bool pascal = curve.size() > 1 && std::adjacent_find(curve.begin(), curve.end(), [](const VfPoint& a, const VfPoint& b) { + const long long d3 = 3LL * std::abs(b.freq_khz - a.freq_khz); + return std::llabs(d3 - (d3 + 19000) / 38000 * 38000) > 1500; + }) == curve.end(); + if (pascal) return 12667; int grid = 0; for (std::size_t i = 1; i < curve.size(); ++i) grid = std::gcd(grid, std::abs(curve[i].freq_khz - curve[i - 1].freq_khz)); return grid > 0 ? grid : 1; diff --git a/src/core/vf_curve.hpp b/src/core/vf_curve.hpp index 66f4e67..b868b6a 100644 --- a/src/core/vf_curve.hpp +++ b/src/core/vf_curve.hpp @@ -37,9 +37,32 @@ inline constexpr std::size_t kVfControlOffset = 20; inline constexpr int kVfSlots = 255; inline constexpr unsigned kVfDomainGraphics = 0; -// The enabled points of the graphics clock, lowest voltage first. -std::vector parse_vf_curve(const unsigned char* mask, const unsigned char* status, const unsigned char* control); -void put_vf_raw_offset(unsigned char* control, int index, int raw); +// The unit of a point's frequency and of its offset is the card's. From the +// RTX 20 series on it is kHz: the reference RTX 4070 stores an offset one to +// one (hardware check 61). A GTX 10 card (Pascal) counts both in half kHz: +// 1873 MHz reads 3746000. NVIDIA documents neither. The half kHz are known +// from a GTX 1080 on driver 582.78 (1556.5 MHz at 850 mV read 3113000, 1873 +// MHz at 1050 mV read 3746000) and from two projects that drive these calls +// on such cards: nvapi-rs counts the curve in half kHz, green-curve halves +// both fields for this architecture. A GTX 1080 Ti on driver 582.78 confirmed +// it with this build (hardware check 110). Read as kHz, such a curve lies twice as high as the +// clock the card reports, and an undervolt search ended before its first +// candidate with "the load clock is below the card's curve". +// +// Both fields are brought to kHz where the buffers are read and written +// (units_per_khz below), so nothing else knows about it. kVfArchPascal is +// NV_GPU_ARCHITECTURE_GP100 of NVIDIA's nvapi.h: what NvAPI_GPU_GetArchInfo +// answers for every GTX 10 chip. +inline constexpr unsigned kVfArchPascal = 0x130; +// How many of the card's units make a kHz: 2 on Pascal, 1 on everything +// else, an architecture that could not be read (0) included. +int vf_units_per_khz(unsigned architecture); + +// The enabled points of the graphics clock, lowest voltage first, in kHz. +std::vector parse_vf_curve(const unsigned char* mask, const unsigned char* status, const unsigned char* control, + int units_per_khz = 1); +// `raw` in kHz, as parse_vf_curve gives it. +void put_vf_raw_offset(unsigned char* control, int index, int raw, int units_per_khz = 1); // How close a point must read to its target. Cards quantise the clock in // steps of up to 15 MHz (RTX 50), so half a step is as close as a target @@ -94,7 +117,8 @@ struct VfApplyResult { // itself (more is an overclock, not an undervolt). // // Offsets are written in kHz, one to one, as the reference RTX 4070 stores -// them (hardware check 61); nothing estimates other units. What protects a +// them (hardware check 61); nothing estimates other units (a GTX 10 card's +// are known by its architecture, see above). What protects a // card that stores them differently is the order of the writes: the first // round raises a point only half of the way, so a card that moves twice as // far as asked lands on the target, not above it; every later round adds @@ -143,7 +167,9 @@ CurveState curve_state(const std::vector& curve, int anchor_uv, int bas // steps between the points. An RTX 5070 runs a 7.5 MHz grid and reads // 2932.5 MHz as 2932: the greatest common divisor of that read-out is 1 MHz, // and every tolerance built on the grid came out a step too small (hardware -// check 99, second run). +// check 99, second run). A GTX 10 card steps in 38/3 MHz, read out as 12.5 or +// 13 MHz; it gets 12667 kHz, not the 0.5 MHz the greatest common divisor +// would give (hardware check 110). int clock_grid_khz(const std::vector& curve); } diff --git a/src/hw/nvapi.cpp b/src/hw/nvapi.cpp index 2a2cd80..9f83ae1 100644 --- a/src/hw/nvapi.cpp +++ b/src/hw/nvapi.cpp @@ -101,6 +101,7 @@ bool Nvapi::Init() { gpus_.assign(handles, handles + count); if (gpus_.empty()) { error_ = "NvAPI_EnumPhysicalGPUs returned no GPUs"; return false; } + vf_units_.assign(gpus_.size(), 0); return true; } @@ -191,6 +192,13 @@ constexpr unsigned kGetVfpCurveId = 0x21537AD4; constexpr unsigned kGetClockBoostTableId = 0x23F1B133; constexpr unsigned kSetClockBoostTableId = 0x0733E009; constexpr unsigned VfVersion(std::size_t size) { return static_cast(size) | (1u << 16); } +// NvAPI_GPU_GetArchInfo, a public call of nvapi.h. NV_GPU_ARCH_INFO, version +// 2: four words, the version, the architecture, the implementation and the +// revision. +constexpr unsigned kGetArchInfoId = 0xD8265D24; +constexpr std::size_t kArchInfoSize = 16; +constexpr unsigned kArchInfoVersion = static_cast(kArchInfoSize) | (2u << 16); +constexpr int kArchInfoArchitecture = 4; // byte offset } bool Nvapi::VfCall(unsigned gpu, unsigned id, const char* name, unsigned char* buf) { @@ -226,6 +234,19 @@ std::optional Nvapi::TempTargetDefaultC(unsigned gpu) { return parse_thermal_policy_default(buf, sizeof(buf)); } +int Nvapi::VfUnitsPerKhz(unsigned gpu) { + if (gpu < vf_units_.size() && vf_units_[gpu] != 0) return vf_units_[gpu]; + unsigned char info[kArchInfoSize] = {}; + PutU32(info, 0, kArchInfoVersion); + // An architecture that cannot be read is taken for one that counts in + // kHz, as every card was before this call was made, and asked again at + // the next read. + if (!VfCall(gpu, kGetArchInfoId, "GetArchInfo", info)) return 1; + const int units = vf_units_per_khz(static_cast(GetI32(info, kArchInfoArchitecture))); + if (gpu < vf_units_.size()) vf_units_[gpu] = units; + return units; +} + bool Nvapi::ReadVfBuffers(unsigned gpu, bool fill_mask, unsigned char* mask, unsigned char* status, unsigned char* control) { std::memset(mask, 0, kVfMaskSize); PutU32(mask, 0, VfVersion(kVfMaskSize)); @@ -246,9 +267,10 @@ bool Nvapi::ReadVfBuffers(unsigned gpu, bool fill_mask, unsigned char* mask, uns std::optional> Nvapi::ReadVfCurve(unsigned gpu) { // Off the stack: 22 kB together. std::vector mask(kVfMaskSize), status(kVfStatusSize), control(kVfControlSize); + const int units = VfUnitsPerKhz(gpu); for (const bool fill_mask : {false, true}) { // some drivers only answer when asked for every slot if (!ReadVfBuffers(gpu, fill_mask, mask.data(), status.data(), control.data())) continue; - auto points = parse_vf_curve(mask.data(), status.data(), control.data()); + auto points = parse_vf_curve(mask.data(), status.data(), control.data(), units); if (!points.empty()) return points; error_ = "the driver reported no curve points"; } @@ -264,9 +286,10 @@ bool Nvapi::WriteVfRawOffsets(unsigned gpu, const std::vector& offsets } if (!read) { if (error_.empty()) error_ = "the curve could not be read before the write"; return false; } // The table as it is now, with only the named slots changed. + const int units = VfUnitsPerKhz(gpu); for (const VfOffset& o : offsets) { if (o.index < 0 || o.index >= kVfSlots) { error_ = "curve slot out of range"; return false; } - put_vf_raw_offset(control.data(), o.index, o.raw); + put_vf_raw_offset(control.data(), o.index, o.raw, units); } PutU32(control.data(), 0, VfVersion(kVfControlSize)); if (!VfCall(gpu, kSetClockBoostTableId, "SetClockBoostTable", control.data())) return false; diff --git a/src/hw/nvapi.hpp b/src/hw/nvapi.hpp index aeb24f2..5d4695a 100644 --- a/src/hw/nvapi.hpp +++ b/src/hw/nvapi.hpp @@ -39,11 +39,16 @@ class Nvapi { // The voltage/frequency curve of the graphics clock (core/vf_curve.hpp), // through three more undocumented calls. Reading may need administrator // rights. nullopt when a call fails or the card reports no points. + // Frequencies and offsets are in kHz on every card. std::optional> ReadVfCurve(unsigned gpu); - // Sets the raw offsets of the named slots and leaves the others as they + // Sets the offsets (kHz) of the named slots and leaves the others as they // are. True when the driver accepted the call; what the card did with it // is for the caller to read back (apply_flat_top does). bool WriteVfRawOffsets(unsigned gpu, const std::vector& offsets); + // How many of the units the card counts its curve in make a kHz + // (vf_units_per_khz): 2 on a GTX 10 card, else 1. Asks the driver for the + // card's architecture once; 1 while that cannot be read. + int VfUnitsPerKhz(unsigned gpu); // The default of the card's temperature target (parse_thermal_policy_default): // what the target is when no tool has set it. A read; needs no @@ -73,6 +78,7 @@ class Nvapi { void* lib_ = nullptr; void* query_ = nullptr; std::vector gpus_; + std::vector vf_units_; // per GPU, as VfUnitsPerKhz answered; 0: not asked yet bool inited_ = false; std::string error_; }; diff --git a/tests/test_vf_curve.cpp b/tests/test_vf_curve.cpp index 02e5380..fdbb5f0 100644 --- a/tests/test_vf_curve.cpp +++ b/tests/test_vf_curve.cpp @@ -132,6 +132,55 @@ TEST_CASE("put_vf_raw_offset writes where parse_vf_curve reads") { CHECK(points[0].raw_offset == -123456); } +TEST_CASE("only a GTX 10 card (Pascal) counts its curve in half kHz") { + CHECK(vf_units_per_khz(kVfArchPascal) == 2); + CHECK(kVfArchPascal == 0x130); // NV_GPU_ARCHITECTURE_GP100 of nvapi.h + // Volta, Turing, Ampere, Ada, Blackwell: kHz, as the reference RTX 4070 stores them (hardware check 61). + for (const unsigned later : {0x140u, 0x160u, 0x170u, 0x190u, 0x1B0u}) CHECK(vf_units_per_khz(later) == 1); + CHECK(vf_units_per_khz(0x120) == 1); // Maxwell: no curve to read, nothing known about it + CHECK(vf_units_per_khz(0) == 1); // the architecture could not be read: as it always was +} + +TEST_CASE("a curve in half kHz is read in kHz: the points of a GTX 1080 are where its clock is") { + std::vector mask(kVfMaskSize), status(kVfStatusSize), control(kVfControlSize); + // What a GTX 1080 answered on driver 582.78: 1556.5 MHz at 850 mV as 3113000, 1873 MHz at 1050 mV as 3746000. + const unsigned freq[] = {3113000, 3746000}; + const unsigned volt[] = {850000, 1050000}; + const int offset[] = {0, 30000}; // +15 MHz on the upper point + for (std::size_t i = 0; i < 2; ++i) { + mask[kVfEntries + i * kVfMaskEntry + 4] = 1; + put_u32(status, kVfEntries + i * kVfStatusEntry + 4, freq[i]); + put_u32(status, kVfEntries + i * kVfStatusEntry + 8, volt[i]); + put_i32(control, kVfEntries + i * kVfControlEntry + kVfControlOffset, offset[i]); + } + const auto points = parse_vf_curve(mask.data(), status.data(), control.data(), vf_units_per_khz(kVfArchPascal)); + REQUIRE(points.size() == 2); + CHECK(points[0].freq_khz == 1556500); + CHECK(points[0].volt_uv == 850000); // voltages are microvolts on every card + CHECK(points[0].raw_offset == 0); + CHECK(points[1].freq_khz == 1873000); + CHECK(points[1].raw_offset == 15000); +} + +TEST_CASE("an offset for a curve in half kHz is stored at twice its kHz, and reads back as what was written") { + std::vector mask(kVfMaskSize), status(kVfStatusSize), control(kVfControlSize); + mask[kVfEntries + 5 * kVfMaskEntry + 4] = 1; + put_u32(status, kVfEntries + 5 * kVfStatusEntry + 4, 3746000); + put_u32(status, kVfEntries + 5 * kVfStatusEntry + 8, 1050000); + auto stored = [&] { + int raw = 0; + std::memcpy(&raw, control.data() + kVfEntries + 5 * kVfControlEntry + kVfControlOffset, sizeof(raw)); + return raw; + }; + for (const int khz : {15000, -45000, 0}) { + put_vf_raw_offset(control.data(), 5, khz, 2); + CHECK(stored() == 2 * khz); + const auto points = parse_vf_curve(mask.data(), status.data(), control.data(), 2); + REQUIRE(points.size() == 1); + CHECK(points[0].raw_offset == khz); + } +} + TEST_CASE("a flat top: the anchor runs the target and reads as the top, the points below stay stock") { FakeCurve card; // Anchor: slot 8 (900 mV, 2300 MHz built in). Target: 2700 MHz, which the card reaches at 1000 mV by itself. @@ -666,6 +715,17 @@ TEST_CASE("the clock grid is read off the curve, also from a read-out cut to who 2932000, 2955000, 2970000, 2992000, 3007000})) == 7500); // Nothing of the usual grids fits: the greatest common divisor, as before. CHECK(clock_grid_khz(points_at({1500000, 1605000, 1700000})) == 5000); + // A GTX 1080 Ti on driver 582.78 (hardware check 110), its curve after the + // halving, all 80 points: 38/3 MHz steps, read as 12.5 or 13 MHz. + CHECK(clock_grid_khz(points_at({227500, 303500, 379500, 455500, 493500, 493500, 493500, 493500, 493500, + 493500, 493500, 518500, 569500, 620000, 670500, 721000, 772000, 822500, + 873000, 923500, 974500, 1025000, 1075500, 1126000, 1177000, 1227500, 1278000, + 1328500, 1379500, 1430000, 1468000, 1493000, 1518500, 1544000, 1569000, 1582000, + 1607000, 1632500, 1645000, 1670500, 1695500, 1708500, 1733500, 1746500, 1759000, + 1784500, 1797000, 1809500, 1835000, 1847500, 1860000, 1873000, 1885500, 1898000, + 1911000, 1923500, 1936000, 1949000, 1961500, 1974000, 1987000, 1987000, 1999500, + 1999500, 1999500, 1999500, 1999500, 1999500, 1999500, 1999500, 1999500, 1999500, + 1999500, 1999500, 1999500, 1999500, 1999500, 1999500, 1999500, 1999500})) == 12667); } TEST_CASE("a card that reads its 7.5 MHz grid in whole MHz: a step of drift after the lowering is accepted") {