Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The one time it asks for you again is after a driver update: a tune belongs to t
What else is in it:

- **Five more profiles** for when you want one thing only: Best of my GPU, Cool & efficient, Quiet, Max performance and Undervolt.
- **You see what it does:** during a run the window draws the card's voltage/frequency curve as the run writes it, over the card's own, and counts every long measurement down.
- **You see what it does:** during a run the window draws the card's voltage/frequency curve as the run writes it, over the card's own, says which step the run is at, and counts every long one down.
- **Fan curves:** the measured one, Silent, Normal, Cool or Aggressive, or your own; fans stop at idle without cycling on and off, the app learns the lowest speed your fans can actually hold, and the fans go back to the driver on exit, sleep, logoff and crashes.
- **Native and small:** two C++ programs, under 2 MB together, with no installer, no runtime and no driver or service.

Expand Down Expand Up @@ -167,8 +167,7 @@ The undervolt is saved in place of an overclock, not next to it: on the card a c

```mermaid
flowchart LR
S[Stock<br/>300 s, to compare with] --> A[Baseline<br/>30 s at stock]
A --> B[Power limit]
S[Stock<br/>300 s: the "before",<br/>and what probes are judged by] --> B[Power limit]
B --> C[Memory offset<br/>bandwidth peak, confirmed,<br/>safety margin]
C --> D[Core offset<br/>single steps, confirmed,<br/>safety margin]
D --> G[Soak<br/>300 s]
Expand Down
3 changes: 3 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ The applied offset is always at least one step below the confirmed edge. Profile
| With its reference handed over, the undervolt search warms its load up before the first candidate | A search that measures its own reference has five minutes of load behind it when the first 3 s candidate runs. One that is handed the reference (the measurement at stock, or the overclock's soak in All in one) starts its load with that candidate, and a load that has not run yet is slower in its first seconds than the card is: it sizes its batches and the card clocks up from idle. The candidate then averages a clock far below the target and reads `SLOW`, and a descent ends at its first failure. Seen three times on the reference card: `1070 mV (+15 MHz): SLOW ... clock=2323 MHz` and `1050 mV: SLOW ... clock=2411 MHz` in hardware check 92, where the undervolt profile then said "the card held no undervolt", and `1025 mV (+150 MHz): SLOW ... clock=2380 MHz` in an All in one run of 2026-10-08. That last one means the undervolt step of All in one has kept the overclock's curve cut off at its chosen clock and searched nothing below it, in every release that has it; its numbers in the README were measured that way. Now the load runs `kUvWarmUpS` (5 s) at the reference's settings first; only whether the card computes is taken from it. The score threshold was not loosened: a candidate that is slow on a load that is running is still `SLOW`. |
| A flat top goes back on a card that is cooler than when it was found | The built-in curve moves with temperature: a point reads up to three steps (45 MHz) more on a cool card than on a warm one. A curve that was only cut off (All in one, when the margin leaves no raise above the overclock) is anchored on the point that ran the target by itself while the search had the card warm. Seconds later, in the next step, the same point read one step more, and `apply_flat_top` refused to put the tune back: "the point already runs more than the target" (hardware check 94). The warm-up of the row above is what made the search read its curve on a warm card; before it, the cut-off was anchored on a card that had idled, and the fault did not show. `apply_flat_top` now takes an anchor that runs up to `kVfTailDropKhz` more than the target and holds it on the target, at the voltage the tune was tested on; more than that is still refused, as not this tune's curve. `curve_state` knows such an anchor (stored below the base offset by no more than that, with the points above cut off) as this program's flat top, so the dashboard and the watchdog do not take it for another program's work. The other direction was always there: a top written on a cool card runs a step lower once it is warm (`kUvClockSlackKhz`). |
| The undervolt search keeps the fans on a curve | It left them to the driver and still stopped every probe at the profile's limit of 75 C. That holds on the reference card, where the driver's fans keep an undervolted card far below it, and cannot hold on a card the driver lets run at 85 C: every probe would end `TOO HOT` and read as "the card held no undervolt". The search now drives the fans as `run_optimize` does: the curve in use (the overclock's own, in an all-in-one run) or the preset's, 100 % at the limit. The undervolt is then tested with the fans it will run with, so the run turns the app's fan control on, as every other tune does. Not verified on a hot card: whether 100 % keeps a 3080 Ti below 75 C at stock power is not known, and the undervolt has no power limit to lower. |
| The clock search takes the five minutes at stock as its baseline | The search judges every probe by a score at stock: below a quarter of it a probe is `STALLED`, a card is back after a reset at nine tenths of it, and the efficient profiles look for the lowest power limit within 2 % of it. It used to take that score in 30 s of its own, right after the five minutes at stock that every profile now starts with (`optimize(..., stock)`); a stock measurement that was not stable is not used, and without one the search still takes its 30 s. The five-minute score is a little higher than a 30 s one on a card that is already hot (5705 against 5662 it/s on the reference card), which makes the 2 % of the efficient profiles slightly stricter. Not yet measured on hardware. |
| The search says which step it is at | `OptimizeIo::measuring`, as the undervolt and the fan tune have it: the step, and its length when it lasts (a confirm probe, the final test, the rest after a reset). A search probe lasts three seconds and gets no countdown, only its name. The window shows one row for all of it, as high with a countdown as without, so the page does not jump. |
| The fan chart draws the curve as set under the line the fans follow | A point below the card's minimum, in the fan-stop zone or past the profile's limit is overridden, and the line leaves it. Without the set curve, thin and dimmed through the points, that read as the line ignoring them. |
| The undervolt run measures stock for as long as it soaks | Five minutes each (`kUvBaselineS = kUvSoakS`; since the row above the measurement at stock is `measure_stock`'s, which lasts as long). A card is not heated through after 30 s: on the reference card the short stock run read 59 to 61 C and 185 W where five minutes gave 67 C at 72 % fan and 196 W, so a result compared with it looked no cooler than stock. The run takes four and a half minutes longer for it. It also puts the clock to keep and the built-in curve on a warm card, as the result will run. The long measurements (stock, confirm, soak) are announced through `UndervoltIo::measuring`, and the run screen counts them down. The result table names the length of both runs and shows the fan speed each ended on next to its peak temperature. The optimize run of the clock profiles measures stock the same way before its search (`measure_stock`, the fans left to the driver) and shows that as the "before"; the search's own 30 s baseline stays, as the reference of the power search and of the stall and health thresholds. The all-in-one run hands its own stock measurement to the overclock step, so nothing is measured twice. |
| Fans through NVML, stop zone via the driver | NVIDIA's legacy NVAPI fan API is gone on RTX 20-series and newer; NVML's `nvmlDeviceSetFanSpeed_v2` is public and verified by reading the target back. Below the stop threshold the driver owns the fans, so no failure of this app can leave them stopped. |
| Tray app plus logon task | Driver settings are volatile: a reboot or driver reset clears them. The logon task starts the tray app, whose watchdog keeps the tune applied; three crashing logons in a row switch it off. |
Expand Down
6 changes: 6 additions & 0 deletions src/app/common.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,12 @@ OptimizeOutcome run_optimize(Preset preset, const OptimizeHooks& hooks, const st
log("the stress load could not be rebuilt: " + load->Error());
return false;
};
io.measuring = hooks.measuring;
// Whatever step the search announced last is over when it returns, however it returns.
struct StepDone {
const OptimizeHooks& hooks;
~StepDone() { if (hooks.measuring) hooks.measuring("", 0); }
} step_done{hooks};
if (hooks.active_gpu) hooks.active_gpu(&gpu);
// The last line of defence: a fault that escapes the per-call guards (in
// the stress load's D3D calls, say) must not kill the process with a
Expand Down
10 changes: 8 additions & 2 deletions src/app/gui/main_gui.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1322,13 +1322,19 @@ int WINAPI wWinMain(HINSTANCE inst, HINSTANCE, PWSTR cmdline, int) {

// Render only when needed: nothing while hidden, a few frames after input,
// ~10 Hz during a run and ~4 Hz otherwise. The tool measures the GPU; its
// own window must not load it.
// own window must not load it. The one exception is a step that counts
// down: at 10 Hz the bar of a 20 s step moves in jumps of several pixels,
// so it gets ~30 Hz while it is on screen. The steps that measure
// bandwidth have no countdown and stay at 10.
for (;;) {
// While the device is lost nothing is drawn either: wait for a message
// (the telemetry timer rebuilds the device, when the backoff lets it)
// instead of polling.
const DWORD timeout =
!g.visible || g.device_lost ? INFINITE : g.input_frames > 0 ? 0 : g.worker->running() ? 100 : 250;
!g.visible || g.device_lost ? INFINITE
: g.input_frames > 0 ? 0
: !g.worker->running() ? 250
: g.worker->snapshot().measuring_seconds > 0 ? 33 : 100;
MsgWaitForMultipleObjectsEx(0, nullptr, timeout, QS_ALLINPUT, MWMO_INPUTAVAILABLE);
MSG msg;
bool quit = false;
Expand Down
66 changes: 41 additions & 25 deletions src/app/gui/ui.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -584,26 +584,33 @@ bool curve_editor(FanCurve& c, int min_pct, int max_temp_c, int now_temp_c, int*
const ImVec2 n = to_screen(static_cast<float>(std::min(now_temp_c, 100)), 0);
dl->AddLine(ImVec2(n.x, a.y), ImVec2(n.x, b.y), ImGui::GetColorU32(kText), 1.0f);
}
// The curve as it was set: straight from point to point, flat outside them.
auto as_set = [&](float t) {
const auto& pts = c.points;
if (t <= static_cast<float>(pts.front().temp_c)) return static_cast<float>(pts.front().pct);
for (size_t k = 1; k < pts.size(); ++k) {
if (t > static_cast<float>(pts[k].temp_c)) continue;
const float f = (t - static_cast<float>(pts[k - 1].temp_c)) / static_cast<float>(pts[k].temp_c - pts[k - 1].temp_c);
return static_cast<float>(pts[k - 1].pct) + f * static_cast<float>(pts[k].pct - pts[k - 1].pct);
}
return static_cast<float>(pts.back().pct);
};
// What the fans actually do, guard rails included: exact, not rounded to
// whole percents, so straight segments stay straight and the jumps at the
// stop threshold and the limit are vertical.
auto effective = [&](float t) {
if (t >= static_cast<float>(max_temp_c)) return 100.0f;
if (c.stop_below_c && t < static_cast<float>(*c.stop_below_c)) return 0.0f;
const auto& pts = c.points;
float v = static_cast<float>(pts.back().pct);
if (t <= static_cast<float>(pts.front().temp_c)) {
v = static_cast<float>(pts.front().pct);
} else {
for (size_t k = 1; k < pts.size(); ++k) {
if (t > static_cast<float>(pts[k].temp_c)) continue;
const float f = (t - static_cast<float>(pts[k - 1].temp_c)) / static_cast<float>(pts[k].temp_c - pts[k - 1].temp_c);
v = static_cast<float>(pts[k - 1].pct) + f * static_cast<float>(pts[k].pct - pts[k - 1].pct);
break;
}
}
return std::max(v, static_cast<float>(min_pct));
return std::max(as_set(t), static_cast<float>(min_pct));
};
// Thin and dimmed through the points, under the line the fans follow: where
// the two part, a guard rail is why, and the points there are not ignored
// by mistake.
std::vector<ImVec2> set_line{to_screen(20, as_set(20))};
for (const FanPoint& pt : c.points) set_line.push_back(to_screen(static_cast<float>(pt.temp_c), static_cast<float>(pt.pct)));
set_line.push_back(to_screen(100, as_set(100)));
dl->AddPolyline(set_line.data(), static_cast<int>(set_line.size()),
ImGui::GetColorU32(ImVec4(kAccentBright.x, kAccentBright.y, kAccentBright.z, 0.40f)), ImDrawFlags_None, 1.2f);
std::vector<ImVec2> line;
for (int i = 0; i <= 80 * 8; ++i) {
const float t = 20 + static_cast<float>(i) / 8;
Expand Down Expand Up @@ -928,19 +935,28 @@ void run_screen(UiState& s, const OptimizeWorker::Snapshot& run, const UiActions
if (ImGui::Button("Cancel")) act.answer_busy(BusyAnswer::Stop);
ImGui::Spacing();
}
// A long measurement: what it is and how long it still takes.
if (run.running && !run.measuring.empty() && run.measuring_seconds > 0) {
const double left = std::max(0.0, run.measuring_seconds - run.measuring_spent);
char time[48];
std::snprintf(time, sizeof(time), "%d:%02d left of %s", static_cast<int>(left) / 60, static_cast<int>(left) % 60,
duration_text(run.measuring_seconds).c_str());
// The step the run is at. One that lasts counts down; one that takes
// seconds, or as long as it takes, only says what it is. The row is as
// high either way, so the page does not jump from step to step.
if (run.running && !run.measuring.empty()) {
text_bold((ic(kIconClock) + " " + run.measuring).c_str(), 1.1f);
ImGui::SameLine(0, em());
ImGui::TextUnformatted(time);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, kAccentBright);
ImGui::PushStyleColor(ImGuiCol_FrameBg, kInner);
ImGui::ProgressBar(static_cast<float>(std::min(1.0, run.measuring_spent / run.measuring_seconds)), ImVec2(-1, em() * 0.5f), "");
ImGui::PopStyleColor(2);
const ImVec2 bar(-1, em() * 0.5f);
if (run.measuring_seconds > 0) {
const double left = std::max(0.0, run.measuring_seconds - run.measuring_spent);
char time[48];
std::snprintf(time, sizeof(time), "%d:%02d left of %s", static_cast<int>(left) / 60, static_cast<int>(left) % 60,
duration_text(run.measuring_seconds).c_str());
// At the right edge, over the end of the bar it belongs to: the
// step's name changes length from step to step, the time does not.
align_right(ImGui::CalcTextSize(time).x);
ImGui::TextUnformatted(time);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, kAccentBright);
ImGui::PushStyleColor(ImGuiCol_FrameBg, kInner);
ImGui::ProgressBar(static_cast<float>(std::min(1.0, run.measuring_spent / run.measuring_seconds)), bar, "");
ImGui::PopStyleColor(2);
} else {
ImGui::Dummy(ImVec2(1, bar.y));
}
ImGui::Spacing();
}
telemetry_tiles(s);
Expand Down
Loading