From 29adc8708b690c017f455255646cac899ef63695 Mon Sep 17 00:00:00 2001 From: Bejeweld Date: Sun, 18 Oct 2026 11:15:13 +0200 Subject: [PATCH 01/10] test(search): split the search tests by topic Move the modelled card, the run fixture and the journal and event helpers into tests/search_fixture.hpp as inline functions, and spread the test cases over files for the bandwidth scan, the search bounds, the recovery gate, the reset budget and the checks against stock. Test names and assertions are unchanged. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 5 + tests/search_fixture.hpp | 440 ++++ tests/test_search.cpp | 2972 +--------------------------- tests/test_search_bandwidth.cpp | 521 +++++ tests/test_search_bounds.cpp | 255 +++ tests/test_search_recovery.cpp | 670 +++++++ tests/test_search_reset_budget.cpp | 893 +++++++++ tests/test_search_stock.cpp | 192 ++ 8 files changed, 2983 insertions(+), 2965 deletions(-) create mode 100644 tests/search_fixture.hpp create mode 100644 tests/test_search_bandwidth.cpp create mode 100644 tests/test_search_bounds.cpp create mode 100644 tests/test_search_recovery.cpp create mode 100644 tests/test_search_reset_budget.cpp create mode 100644 tests/test_search_stock.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 50bf1ea..a4208db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -148,6 +148,11 @@ add_executable(core_tests tests/test_stability.cpp tests/test_journal.cpp tests/test_search.cpp + tests/test_search_bandwidth.cpp + tests/test_search_bounds.cpp + tests/test_search_recovery.cpp + tests/test_search_reset_budget.cpp + tests/test_search_stock.cpp tests/test_busy_card.cpp tests/test_boot.cpp tests/test_retest.cpp diff --git a/tests/search_fixture.hpp b/tests/search_fixture.hpp new file mode 100644 index 0000000..6c20df0 --- /dev/null +++ b/tests/search_fixture.hpp @@ -0,0 +1,440 @@ +// The fixture the search tests share: a modelled card, the run that drives +// optimize() against it, and the helpers that read its journal and events. +#pragma once +#include "doctest/doctest.h" +#include "core/search.hpp" +#include "core/journal.hpp" +#include "core/objectives.hpp" +#include +#include +#include +#include +#include +#include + +using namespace gao; + +// A modelled card: stable up to core +150 / mem +800; peak temp = 40 + +// 0.3 * power%; score rises with power up to 90 %, flat above. +struct FakeCard { + int power = 100, core = 0, mem = 0; + int core_edge = 150, mem_edge = 800; + bool stock_unstable = false; + int soak_failures = 0; // how many 300 s probes fail before one passes + int noisy_power_pct = -1; // a 20 s probe at this power reads 10 % low + int soak_extra_heat = 0; // a 300 s probe peaks this much hotter than a 20 s one + int confirm_extra_heat = 0; // a 30 s probe peaks this much hotter than a 20 s one + int extra_heat = 0; // every probe peaks this much hotter: a card that runs warm + double hot_after_health_s = -1; // >= 0: the first 30 s probe after a health probe is too hot after this many seconds + bool hot_from_health = false; // armed by that health probe + int abort_in_probe_s = -1; // a probe of this length is cut short by a stop request + bool abort_on_soak = false; // Ctrl+C arrives while the soak probe runs + bool aborted_now = false; + int reset_calls = 0, reset_fails_from = -1; // reset_to_stock fails from this call on + int completes_ok = -1; // journal 'complete' writes that succeed before they fail + int fail_core_set_at = -1; // set_core_offset(this) returns false + int max_core_seen = 0; + std::vector confirm_fail_core; // a 30 s probe at these core offsets fails + std::function bw_curve; // GB/s by memory offset; empty = no bandwidth + bool bw_fails = false; // every bandwidth measurement fails + int max_mem_seen = 0; + int stall_core_from = -1; // at or above this core offset a probe "passes" at 3 % of the normal score + int lost_core_from = -1; // at or above this core offset a probe ends DEVICE LOST and the driver resets + bool stale = false; // after a driver reset: every write fails until recover() + bool with_recover = false; // the card offers GpuControl::recover + bool recover_fails = false; + int recover_calls = 0; + int blind_probes = 0; // this many 3 s probes report NO TELEMETRY + int flaky_core_sets = 0; // this many set_core_offset calls fail although nothing is wrong + int begins_when_recover_failed = -1; // journal `begin` lines at the moment recover() first failed + int recovers_with_entry_open = 0; // recover() calls made while the last journal line was a `begin` + bool abort_in_recover = false; // a stop request arrives while recover() runs + int lost_at_probe = -1; // the N-th probe of the run ends DEVICE LOST and the driver resets + bool bw_loses_device = false; // a bandwidth measurement fails and the driver resets + int sick_health_probes = 0; // this many 5 s probes after a reset score 50 % of normal (computing, not back) + std::optional health_verdict; // the first 5 s probe ends with this verdict instead + bool health_stalls = false; // the first 5 s probe "passes" at 3 % of the normal score + bool stock_write_fails_after_recover = false; // reset_to_stock fails once, right after a successful recover + bool fail_next_stock = false; // armed by that recover, cleared by the reset it fails + std::vector events; // "recover", "stock", "rest", "health", "begin" in the order they happen + bool abort_during_rest = false; // a stop request arrives while the search rests + int rests = 0; + int abort_on_rest = -1; // a stop request comes during this rest (1 = the first) + bool with_prepare = false; // the run offers OptimizeIo::prepare_load + int prepare_fails = 0; // this many preparations return false + int prepares = 0; + int prepares_with_entry_open = 0; // preparations made while the last journal line was a `begin` + bool abort_after_prepare = false; // a stop request arrives while the load is being prepared + int probes = 0, power_probes = 0; + std::vector journal; + std::string last_set_begin_ok; // "" if every set had its begin line first + std::optional ranges; // what the card reports; empty = it reports nothing + bool ranges_read_fails = false; // the callback exists but returns nothing + std::vector mem_during_core_probes; // the memory offset applied at each probe with a core offset + int blind_at_core = -1; // the 3 s probe at this core offset reports NO TELEMETRY + int fail_core_set_once_at = -1; // the first set_core_offset(this) fails; nothing is wrong with the card + bool fail_next_core_set_after_recover = false; // the first non-zero core write after a recover fails once + bool fail_next_core_set = false; // armed by that recover, cleared by the write it fails + int flaky_power_sets = 0; // this many set_power_limit calls fail although nothing is wrong + bool lost_on_first_core_confirm = false; // the first 30 s probe with a non-zero core offset ends DEVICE LOST + bool lost_on_first_mem_confirm = false; // the first 30 s probe with a non-zero memory offset ends DEVICE LOST + int bw_measurements = 0; // bandwidth measurements taken with the short copy + // The readings of the second pass: those taken below an offset the card + // has already been at. bw_again_noise: what they are off by, when that is + // to differ from bw_noise. From bw_again_loses_device_from on, such a + // reading takes the device down. + int bw_again_loses_device_from = INT_MAX; + std::function bw_again_noise; + int bw_again_measurements = 0; + int max_again_mem = -1; // the highest offset one was taken at + bool bw_fails_at_zero = false; // the measurement at memory +0 fails; nothing is wrong with the card + bool lost_on_first_soak = false; // the first 300 s probe ends DEVICE LOST + bool fail_set_on_first_soak = false; // the first write of the soak state fails once + int bw_fails_once_at_mem = -1; // the bandwidth measurement at this offset fails once and the driver resets + int bw_unsettled_at_mem = -1; // the bandwidth readings at this offset do not agree; the driver is fine + int bw_unsettled_once_at_mem = -1; // the first reading at this offset does not settle, the next ones do + // What a reading is off by: a factor on the curve, for the n-th reading + // (from 0) taken at that offset. Empty: none. + std::function bw_noise; + std::map bw_readings_at; // readings taken so far, by offset + bool bw_unsettled = false; // what the last failed measurement was + int lost_mem_from = -1; // at or above this memory offset a 3 s probe ends DEVICE LOST + int recover_fails_from_call = -1; // recover() fails from this call on (1 = the first) + std::vector probe_order; // every probe as "seconds:power:core:mem", in the order they ran + bool fail_power_set_in_entry = false; // the first power write made while a journal entry is open fails once + std::vector> probe_floors; // every probe as (seconds, stall_below), in the order they ran + + bool entry_open() const { return !journal.empty() && journal.back().find("\"begin\"") != std::string::npos; } + // True while the journal's last line opens the entry of a soak with both + // clocks above stock: the only entry that carries both a core and a memory + // offset. + bool soak_entry_open() const { + if (journal.empty()) return false; + const std::string& l = journal.back(); + return l.find("\"begin\"") != std::string::npos && l.find("\"core\":") != std::string::npos && + l.find("\"mem\":") != std::string::npos; + } + + GpuControl gpu(bool with_power = true, std::pair range = {50, 120}) { + GpuControl g; + g.read = [this] { Telemetry t; t.ok = true; t.temp_c = 40; t.core_mhz = 2800; return t; }; + g.set_core_offset = [this](int v) { + if (stale) return false; + if (v != 0 && flaky_core_sets > 0) { --flaky_core_sets; return false; } + if (v != 0 && fail_next_core_set) { fail_next_core_set = false; return false; } + if (v == fail_core_set_once_at) { fail_core_set_once_at = -1; return false; } + if (fail_set_on_first_soak && soak_entry_open()) { fail_set_on_first_soak = false; return false; } + // Every non-stock clock must be preceded by an open journal line. + if (v != 0 && (journal.empty() || journal.back().find("\"begin\"") == std::string::npos)) + last_set_begin_ok = "core " + std::to_string(v) + " set without begin"; + if (v == fail_core_set_at) return false; + core = v; max_core_seen = std::max(max_core_seen, v); return true; + }; + g.set_mem_offset = [this](int v) { if (stale) return false; mem = v; max_mem_seen = std::max(max_mem_seen, v); return true; }; + g.reset_to_stock = [this] { + if (stale) return false; + if (fail_next_stock) { fail_next_stock = false; return false; } + if (reset_fails_from >= 0 && ++reset_calls > reset_fails_from) return false; + events.push_back("stock"); + power = 100; core = 0; mem = 0; return true; + }; + if (with_power) { + g.set_power_limit = [this](int p) { + if (stale) return false; + if (flaky_power_sets > 0) { --flaky_power_sets; return false; } + if (fail_power_set_in_entry && entry_open()) { fail_power_set_in_entry = false; return false; } + power = p; return true; + }; + g.power_limit_range_pct = [range] { return range; }; + } + if (ranges || ranges_read_fails) { + g.clock_offset_range_mhz = [this]() -> std::optional { + if (ranges_read_fails) return std::nullopt; + return ranges; + }; + g.read_applied = [this] { return std::optional(AppliedState{core, mem, power}); }; + } + if (with_recover) + g.recover = [this] { + ++recover_calls; + events.push_back("recover"); + // The reconnect can crash or hang: it must never run inside an + // open journal entry, or that candidate becomes a false ceiling. + if (!journal.empty() && journal.back().find("\"begin\"") != std::string::npos) ++recovers_with_entry_open; + if (abort_in_recover) aborted_now = true; + if (recover_fails || (recover_fails_from_call >= 0 && recover_calls >= recover_fails_from_call)) { + if (begins_when_recover_failed < 0) { + begins_when_recover_failed = 0; + for (const auto& l : journal) begins_when_recover_failed += l.find("\"begin\"") != std::string::npos; + } + return false; + } + stale = false; + if (stock_write_fails_after_recover) { stock_write_fails_after_recover = false; fail_next_stock = true; } + if (fail_next_core_set_after_recover) { fail_next_core_set_after_recover = false; fail_next_core_set = true; } + return true; + }; + return g; + } + // What a real TDR does: the driver comes back at stock and the old + // connections to it are dead. + void driver_reset() { core = 0; mem = 0; power = 100; stale = true; } + int count(const std::string& what) const { + int n = 0; + for (const auto& l : journal) n += l.find(what) != std::string::npos; + return n; + } + Probe probe() { + return [this](double seconds, int max_temp, double stall_below) { + ++probes; + probe_floors.emplace_back(seconds, stall_below); + if (seconds == 20) ++power_probes; + if (core != 0) mem_during_core_probes.push_back(mem); + StabilityResult r; + r.seconds = seconds; + probe_order.push_back(std::to_string(static_cast(seconds)) + ":" + std::to_string(power) + ":" + + std::to_string(core) + ":" + std::to_string(mem)); + // The 5 s probe is the health probe after a driver reset. + const bool health = seconds == 5; + if (health) events.push_back("health"); + if (health && hot_after_health_s >= 0) hot_from_health = true; + if (seconds == 30 && hot_from_health) { // the card was above the limit when the probe began + hot_from_health = false; + r.seconds = hot_after_health_s; + r.peak_temp_c = max_temp + 1; + r.verdict = Verdict::TooHot; + return r; + } + if (seconds == abort_in_probe_s) { r.verdict = Verdict::Aborted; return r; } + if (seconds == 3 && blind_probes > 0) { --blind_probes; r.verdict = Verdict::NoTelemetry; return r; } + if (seconds == 3 && core == blind_at_core) { r.verdict = Verdict::NoTelemetry; return r; } + if (health && health_verdict) { + r.verdict = *health_verdict; + health_verdict.reset(); + if (r.verdict == Verdict::DeviceLost) driver_reset(); + return r; + } + bool lost = probes == lost_at_probe || (lost_core_from >= 0 && core >= lost_core_from) || + (seconds == 3 && lost_mem_from >= 0 && mem >= lost_mem_from); + if (seconds == 30 && core != 0 && lost_on_first_core_confirm) { lost_on_first_core_confirm = false; lost = true; } + if (seconds == 30 && mem != 0 && lost_on_first_mem_confirm) { lost_on_first_mem_confirm = false; lost = true; } + if (seconds == 300 && lost_on_first_soak) { lost_on_first_soak = false; lost = true; } + if (lost) { + driver_reset(); + r.verdict = Verdict::DeviceLost; + return r; + } + r.peak_temp_c = static_cast(40 + 0.3 * power) + extra_heat + (seconds == 300 ? soak_extra_heat : 0) + + (seconds == 30 ? confirm_extra_heat : 0); + if (seconds == 300 && abort_on_soak) aborted_now = true; + r.score = 1000.0 * std::min(power, 90) / 90; + if (health && health_stalls) { health_stalls = false; r.score *= 0.03; return r; } // verdict stays Stable + // Half the normal score is above kStalledScore: a slow card, not a reset verdict. + if (health && sick_health_probes > 0) { --sick_health_probes; r.score *= 0.5; } + if (stall_core_from >= 0 && core >= stall_core_from) { r.score *= 0.03; return r; } // verdict stays Stable + if (seconds == 20 && power == noisy_power_pct) r.score *= 0.9; + if (seconds == 30 && std::find(confirm_fail_core.begin(), confirm_fail_core.end(), core) != confirm_fail_core.end()) { + r.verdict = Verdict::WrongResult; + return r; + } + if (stock_unstable || core > core_edge || mem > mem_edge) r.verdict = Verdict::WrongResult; + else if (seconds == 300 && soak_failures > 0) { --soak_failures; r.verdict = Verdict::WrongResult; } + else if (r.peak_temp_c > max_temp) r.verdict = Verdict::TooHot; + return r; + }; + } +}; + +struct Run { + FakeCard card; + std::vector log; + int abort_after_probes = -1; + bool no_rest = false; // the application gives the search no way to wait + std::optional stock; // the measurement at stock that went before the run, when there was one + std::string rig = "card-a / 617.42"; // the card and driver the journal's memory edges are bound to + std::vector> steps; // what the search said it was doing, and for how long + OptimizeResult go(Preset preset, GpuControl gpu) { + Journal j(card.journal, [this](const std::string& l) { + if (l.find("\"complete\"") != std::string::npos && card.completes_ok >= 0 && card.completes_ok-- == 0) + return false; + if (l.find("\"begin\"") != std::string::npos) card.events.push_back("begin"); + card.journal.push_back(l); + return true; + }, rig); + OptimizeIo io; + io.probe = card.probe(); + io.aborted = [this] { return card.aborted_now || (abort_after_probes >= 0 && card.probes >= abort_after_probes); }; + io.log = [this](const std::string& m) { log.push_back(m); }; + if (!no_rest) + io.rest = [this](double) { + ++card.rests; + card.events.push_back("rest"); + if (card.abort_during_rest || card.rests == card.abort_on_rest) { card.aborted_now = true; return false; } + return true; + }; + if (card.with_prepare) + io.prepare_load = [this] { + ++card.prepares; + card.events.push_back("prepare"); + if (card.entry_open()) ++card.prepares_with_entry_open; + if (card.abort_after_prepare) card.aborted_now = true; + if (card.prepare_fails > 0) { --card.prepare_fails; return false; } + return true; + }; + if (card.bw_curve) + io.bandwidth = [this]() -> std::optional { + const int nth = card.bw_readings_at[card.mem]++; + if (card.mem < card.max_mem_seen) { // the second pass + ++card.bw_again_measurements; + card.max_again_mem = std::max(card.max_again_mem, card.mem); + card.bw_unsettled = false; + if (card.mem >= card.bw_again_loses_device_from) { card.driver_reset(); return std::nullopt; } + const auto& noise = card.bw_again_noise ? card.bw_again_noise : card.bw_noise; + return card.bw_curve(card.mem) * (noise ? noise(card.mem, nth) : 1.0); + } + ++card.bw_measurements; + card.bw_unsettled = card.mem == card.bw_unsettled_at_mem || (card.mem == card.bw_unsettled_once_at_mem && nth == 0); + if (card.bw_unsettled) return std::nullopt; + if (card.bw_loses_device) { card.driver_reset(); return std::nullopt; } + if (card.bw_fails_at_zero && card.mem == 0) return std::nullopt; + if (card.mem == card.bw_fails_once_at_mem) { + card.bw_fails_once_at_mem = -1; + card.driver_reset(); + return std::nullopt; + } + if (card.bw_fails) return std::nullopt; + return card.bw_curve(card.mem) * (card.bw_noise ? card.bw_noise(card.mem, nth) : 1.0); + }; + if (card.bw_curve) io.bandwidth_unsettled = [this] { return card.bw_unsettled; }; + io.measuring = [this](const std::string& what, double seconds) { steps.emplace_back(what, seconds); }; + return optimize(gpu, objectives_for(preset), j, io, stock); + } + OptimizeResult go(Preset preset) { return go(preset, card.gpu()); } + bool said(const std::string& what, double seconds) const { + return std::find(steps.begin(), steps.end(), std::make_pair(what, seconds)) != steps.end(); + } + bool logged(const std::string& what) const { return count_logged(what) > 0; } + int count_logged(const std::string& what) const { + int n = 0; + for (const auto& m : log) n += m.find(what) != std::string::npos; + return n; + } +}; + +using Events = std::vector; + +inline int count_events(const FakeCard& card, const std::string& what) { + return static_cast(std::count(card.events.begin(), card.events.end(), what)); +} + +// Everything that happened after the journal entry of the candidate the +// driver reset under was opened: the events after the last "begin" before the +// first "recover". +inline Events events_after_lost(const FakeCard& card) { + const auto first_recover = std::find(card.events.begin(), card.events.end(), "recover"); + auto from = first_recover; + while (from != card.events.begin() && *(from - 1) != "begin") --from; + if (from == card.events.begin()) return {}; // no candidate was open before the first reconnect + return {from, card.events.end()}; +} + +// The events after the last journal entry of the run was opened. +inline Events events_after_last_begin(const FakeCard& card) { + auto from = card.events.end(); + while (from != card.events.begin() && *(from - 1) != "begin") --from; + return {from, card.events.end()}; +} + +// The soak entries of a run whose soak has both clocks above stock: the +// `begin` lines that carry both a core and a memory offset. (A soak journals +// only the clocks that are above stock.) +inline int soak_entries(const FakeCard& card) { + int n = 0; + for (const auto& l : card.journal) + n += l.find("\"begin\"") != std::string::npos && l.find("\"core\":") != std::string::npos && + l.find("\"mem\":") != std::string::npos; + return n; +} + +// The first n of them. +inline Events first_events_after_lost(const FakeCard& card, std::size_t n) { + auto all = events_after_lost(card); + if (all.size() > n) all.resize(n); + return all; +} + +// The offsets of the journal's `begin` lines that carry `field` and not +// `other`, in the order they were written. +inline std::vector begins_with_only(const FakeCard& card, const std::string& field, const std::string& other) { + std::vector out; + const std::string key = "\"" + field + "\":"; + for (const auto& l : card.journal) { + if (l.find("\"begin\"") == std::string::npos || l.find("\"" + other + "\":") != std::string::npos) continue; + const auto at = l.find(key); + if (at != std::string::npos) out.push_back(std::stoi(l.substr(at + key.size()))); + } + return out; +} + +// What every run with a reconnect must leave behind: no reconnect was made +// inside an open journal entry, and no entry is left open. +inline void check_journal_rules(const Run& run) { + CHECK(run.card.recovers_with_entry_open == 0); + CHECK(run.card.count("\"begin\"") == run.card.count("\"complete\"")); + Journal reread(run.card.journal, [](const std::string&) { return true; }); + CHECK(reread.freezes().empty()); +} + +// A reset the probe reported is handled by the gate before the next step +// starts: no write is tried on the dead connections first, which would fail +// and count as a second reset event. +inline void check_reconnected_up_front(const Run& run) { + CHECK(run.logged("the driver was reset; reconnecting")); + CHECK_FALSE(run.logged("trying once more")); + CHECK(run.card.count("SET FAILED") == 0); +} + +// The recovery gate. Unless stated, the card loses the device at core +135: +// probe 32 of the run, the only driver reset, between the climb and the +// confirm step. +inline Run lost_at_135() { + Run run; + run.card.with_recover = true; + run.card.lost_core_from = 135; + return run; +} + +inline Run recovering() { + Run run; + run.card.with_recover = true; + return run; +} + +// from, from + step, ... to, followed by `then`. +inline std::vector climb(int from, int to, int step, std::vector then = {}) { + std::vector out; + for (int v = from; v <= to; v += step) out.push_back(v); + out.insert(out.end(), then.begin(), then.end()); + return out; +} + +inline std::vector core_begins(const FakeCard& card) { return begins_with_only(card, "core", "mem"); } +inline std::vector mem_begins(const FakeCard& card) { return begins_with_only(card, "mem", "core"); } +// The `begin` lines that carry neither clock: the +0 sample of the bandwidth +// scan, and a soak with both clocks at stock. +inline int clockless_begins(const FakeCard& card) { + int n = 0; + for (const auto& l : card.journal) + n += l.find("\"begin\"") != std::string::npos && l.find("\"core\":") == std::string::npos && + l.find("\"mem\":") == std::string::npos; + return n; +} + +// Nothing that loads the card or waits for it, and no journal entry. +inline void check_nothing_started(const Events& events) { + for (const auto& e : events) { + CHECK(e != "rest"); + CHECK(e != "health"); + CHECK(e != "begin"); + } +} diff --git a/tests/test_search.cpp b/tests/test_search.cpp index ee3be79..3684fbd 100644 --- a/tests/test_search.cpp +++ b/tests/test_search.cpp @@ -1,15 +1,5 @@ #include "doctest/doctest.h" -#include "core/search.hpp" -#include "core/journal.hpp" -#include "core/objectives.hpp" -#include -#include -#include -#include -#include -#include - -using namespace gao; +#include "search_fixture.hpp" TEST_CASE("highest_stable finds the edge of a monotonic range") { std::vector probed; @@ -65,388 +55,6 @@ TEST_CASE("margin survives float rounding") { CHECK(apply_margin(150, 15, 0.7f) == 105); // 0.7f * 150 / 15 = 6.99999... } -namespace { -// A modelled card: stable up to core +150 / mem +800; peak temp = 40 + -// 0.3 * power%; score rises with power up to 90 %, flat above. -struct FakeCard { - int power = 100, core = 0, mem = 0; - int core_edge = 150, mem_edge = 800; - bool stock_unstable = false; - int soak_failures = 0; // how many 300 s probes fail before one passes - int noisy_power_pct = -1; // a 20 s probe at this power reads 10 % low - int soak_extra_heat = 0; // a 300 s probe peaks this much hotter than a 20 s one - int confirm_extra_heat = 0; // a 30 s probe peaks this much hotter than a 20 s one - int extra_heat = 0; // every probe peaks this much hotter: a card that runs warm - double hot_after_health_s = -1; // >= 0: the first 30 s probe after a health probe is too hot after this many seconds - bool hot_from_health = false; // armed by that health probe - int abort_in_probe_s = -1; // a probe of this length is cut short by a stop request - bool abort_on_soak = false; // Ctrl+C arrives while the soak probe runs - bool aborted_now = false; - int reset_calls = 0, reset_fails_from = -1; // reset_to_stock fails from this call on - int completes_ok = -1; // journal 'complete' writes that succeed before they fail - int fail_core_set_at = -1; // set_core_offset(this) returns false - int max_core_seen = 0; - std::vector confirm_fail_core; // a 30 s probe at these core offsets fails - std::function bw_curve; // GB/s by memory offset; empty = no bandwidth - bool bw_fails = false; // every bandwidth measurement fails - int max_mem_seen = 0; - int stall_core_from = -1; // at or above this core offset a probe "passes" at 3 % of the normal score - int lost_core_from = -1; // at or above this core offset a probe ends DEVICE LOST and the driver resets - bool stale = false; // after a driver reset: every write fails until recover() - bool with_recover = false; // the card offers GpuControl::recover - bool recover_fails = false; - int recover_calls = 0; - int blind_probes = 0; // this many 3 s probes report NO TELEMETRY - int flaky_core_sets = 0; // this many set_core_offset calls fail although nothing is wrong - int begins_when_recover_failed = -1; // journal `begin` lines at the moment recover() first failed - int recovers_with_entry_open = 0; // recover() calls made while the last journal line was a `begin` - bool abort_in_recover = false; // a stop request arrives while recover() runs - int lost_at_probe = -1; // the N-th probe of the run ends DEVICE LOST and the driver resets - bool bw_loses_device = false; // a bandwidth measurement fails and the driver resets - int sick_health_probes = 0; // this many 5 s probes after a reset score 50 % of normal (computing, not back) - std::optional health_verdict; // the first 5 s probe ends with this verdict instead - bool health_stalls = false; // the first 5 s probe "passes" at 3 % of the normal score - bool stock_write_fails_after_recover = false; // reset_to_stock fails once, right after a successful recover - bool fail_next_stock = false; // armed by that recover, cleared by the reset it fails - std::vector events; // "recover", "stock", "rest", "health", "begin" in the order they happen - bool abort_during_rest = false; // a stop request arrives while the search rests - int rests = 0; - int abort_on_rest = -1; // a stop request comes during this rest (1 = the first) - bool with_prepare = false; // the run offers OptimizeIo::prepare_load - int prepare_fails = 0; // this many preparations return false - int prepares = 0; - int prepares_with_entry_open = 0; // preparations made while the last journal line was a `begin` - bool abort_after_prepare = false; // a stop request arrives while the load is being prepared - int probes = 0, power_probes = 0; - std::vector journal; - std::string last_set_begin_ok; // "" if every set had its begin line first - std::optional ranges; // what the card reports; empty = it reports nothing - bool ranges_read_fails = false; // the callback exists but returns nothing - std::vector mem_during_core_probes; // the memory offset applied at each probe with a core offset - int blind_at_core = -1; // the 3 s probe at this core offset reports NO TELEMETRY - int fail_core_set_once_at = -1; // the first set_core_offset(this) fails; nothing is wrong with the card - bool fail_next_core_set_after_recover = false; // the first non-zero core write after a recover fails once - bool fail_next_core_set = false; // armed by that recover, cleared by the write it fails - int flaky_power_sets = 0; // this many set_power_limit calls fail although nothing is wrong - bool lost_on_first_core_confirm = false; // the first 30 s probe with a non-zero core offset ends DEVICE LOST - bool lost_on_first_mem_confirm = false; // the first 30 s probe with a non-zero memory offset ends DEVICE LOST - int bw_measurements = 0; // bandwidth measurements taken with the short copy - // The readings of the second pass: those taken below an offset the card - // has already been at. bw_again_noise: what they are off by, when that is - // to differ from bw_noise. From bw_again_loses_device_from on, such a - // reading takes the device down. - int bw_again_loses_device_from = INT_MAX; - std::function bw_again_noise; - int bw_again_measurements = 0; - int max_again_mem = -1; // the highest offset one was taken at - bool bw_fails_at_zero = false; // the measurement at memory +0 fails; nothing is wrong with the card - bool lost_on_first_soak = false; // the first 300 s probe ends DEVICE LOST - bool fail_set_on_first_soak = false; // the first write of the soak state fails once - int bw_fails_once_at_mem = -1; // the bandwidth measurement at this offset fails once and the driver resets - int bw_unsettled_at_mem = -1; // the bandwidth readings at this offset do not agree; the driver is fine - int bw_unsettled_once_at_mem = -1; // the first reading at this offset does not settle, the next ones do - // What a reading is off by: a factor on the curve, for the n-th reading - // (from 0) taken at that offset. Empty: none. - std::function bw_noise; - std::map bw_readings_at; // readings taken so far, by offset - bool bw_unsettled = false; // what the last failed measurement was - int lost_mem_from = -1; // at or above this memory offset a 3 s probe ends DEVICE LOST - int recover_fails_from_call = -1; // recover() fails from this call on (1 = the first) - std::vector probe_order; // every probe as "seconds:power:core:mem", in the order they ran - bool fail_power_set_in_entry = false; // the first power write made while a journal entry is open fails once - std::vector> probe_floors; // every probe as (seconds, stall_below), in the order they ran - - bool entry_open() const { return !journal.empty() && journal.back().find("\"begin\"") != std::string::npos; } - // True while the journal's last line opens the entry of a soak with both - // clocks above stock: the only entry that carries both a core and a memory - // offset. - bool soak_entry_open() const { - if (journal.empty()) return false; - const std::string& l = journal.back(); - return l.find("\"begin\"") != std::string::npos && l.find("\"core\":") != std::string::npos && - l.find("\"mem\":") != std::string::npos; - } - - GpuControl gpu(bool with_power = true, std::pair range = {50, 120}) { - GpuControl g; - g.read = [this] { Telemetry t; t.ok = true; t.temp_c = 40; t.core_mhz = 2800; return t; }; - g.set_core_offset = [this](int v) { - if (stale) return false; - if (v != 0 && flaky_core_sets > 0) { --flaky_core_sets; return false; } - if (v != 0 && fail_next_core_set) { fail_next_core_set = false; return false; } - if (v == fail_core_set_once_at) { fail_core_set_once_at = -1; return false; } - if (fail_set_on_first_soak && soak_entry_open()) { fail_set_on_first_soak = false; return false; } - // Every non-stock clock must be preceded by an open journal line. - if (v != 0 && (journal.empty() || journal.back().find("\"begin\"") == std::string::npos)) - last_set_begin_ok = "core " + std::to_string(v) + " set without begin"; - if (v == fail_core_set_at) return false; - core = v; max_core_seen = std::max(max_core_seen, v); return true; - }; - g.set_mem_offset = [this](int v) { if (stale) return false; mem = v; max_mem_seen = std::max(max_mem_seen, v); return true; }; - g.reset_to_stock = [this] { - if (stale) return false; - if (fail_next_stock) { fail_next_stock = false; return false; } - if (reset_fails_from >= 0 && ++reset_calls > reset_fails_from) return false; - events.push_back("stock"); - power = 100; core = 0; mem = 0; return true; - }; - if (with_power) { - g.set_power_limit = [this](int p) { - if (stale) return false; - if (flaky_power_sets > 0) { --flaky_power_sets; return false; } - if (fail_power_set_in_entry && entry_open()) { fail_power_set_in_entry = false; return false; } - power = p; return true; - }; - g.power_limit_range_pct = [range] { return range; }; - } - if (ranges || ranges_read_fails) { - g.clock_offset_range_mhz = [this]() -> std::optional { - if (ranges_read_fails) return std::nullopt; - return ranges; - }; - g.read_applied = [this] { return std::optional(AppliedState{core, mem, power}); }; - } - if (with_recover) - g.recover = [this] { - ++recover_calls; - events.push_back("recover"); - // The reconnect can crash or hang: it must never run inside an - // open journal entry, or that candidate becomes a false ceiling. - if (!journal.empty() && journal.back().find("\"begin\"") != std::string::npos) ++recovers_with_entry_open; - if (abort_in_recover) aborted_now = true; - if (recover_fails || (recover_fails_from_call >= 0 && recover_calls >= recover_fails_from_call)) { - if (begins_when_recover_failed < 0) { - begins_when_recover_failed = 0; - for (const auto& l : journal) begins_when_recover_failed += l.find("\"begin\"") != std::string::npos; - } - return false; - } - stale = false; - if (stock_write_fails_after_recover) { stock_write_fails_after_recover = false; fail_next_stock = true; } - if (fail_next_core_set_after_recover) { fail_next_core_set_after_recover = false; fail_next_core_set = true; } - return true; - }; - return g; - } - // What a real TDR does: the driver comes back at stock and the old - // connections to it are dead. - void driver_reset() { core = 0; mem = 0; power = 100; stale = true; } - int count(const std::string& what) const { - int n = 0; - for (const auto& l : journal) n += l.find(what) != std::string::npos; - return n; - } - Probe probe() { - return [this](double seconds, int max_temp, double stall_below) { - ++probes; - probe_floors.emplace_back(seconds, stall_below); - if (seconds == 20) ++power_probes; - if (core != 0) mem_during_core_probes.push_back(mem); - StabilityResult r; - r.seconds = seconds; - probe_order.push_back(std::to_string(static_cast(seconds)) + ":" + std::to_string(power) + ":" + - std::to_string(core) + ":" + std::to_string(mem)); - // The 5 s probe is the health probe after a driver reset. - const bool health = seconds == 5; - if (health) events.push_back("health"); - if (health && hot_after_health_s >= 0) hot_from_health = true; - if (seconds == 30 && hot_from_health) { // the card was above the limit when the probe began - hot_from_health = false; - r.seconds = hot_after_health_s; - r.peak_temp_c = max_temp + 1; - r.verdict = Verdict::TooHot; - return r; - } - if (seconds == abort_in_probe_s) { r.verdict = Verdict::Aborted; return r; } - if (seconds == 3 && blind_probes > 0) { --blind_probes; r.verdict = Verdict::NoTelemetry; return r; } - if (seconds == 3 && core == blind_at_core) { r.verdict = Verdict::NoTelemetry; return r; } - if (health && health_verdict) { - r.verdict = *health_verdict; - health_verdict.reset(); - if (r.verdict == Verdict::DeviceLost) driver_reset(); - return r; - } - bool lost = probes == lost_at_probe || (lost_core_from >= 0 && core >= lost_core_from) || - (seconds == 3 && lost_mem_from >= 0 && mem >= lost_mem_from); - if (seconds == 30 && core != 0 && lost_on_first_core_confirm) { lost_on_first_core_confirm = false; lost = true; } - if (seconds == 30 && mem != 0 && lost_on_first_mem_confirm) { lost_on_first_mem_confirm = false; lost = true; } - if (seconds == 300 && lost_on_first_soak) { lost_on_first_soak = false; lost = true; } - if (lost) { - driver_reset(); - r.verdict = Verdict::DeviceLost; - return r; - } - r.peak_temp_c = static_cast(40 + 0.3 * power) + extra_heat + (seconds == 300 ? soak_extra_heat : 0) + - (seconds == 30 ? confirm_extra_heat : 0); - if (seconds == 300 && abort_on_soak) aborted_now = true; - r.score = 1000.0 * std::min(power, 90) / 90; - if (health && health_stalls) { health_stalls = false; r.score *= 0.03; return r; } // verdict stays Stable - // Half the normal score is above kStalledScore: a slow card, not a reset verdict. - if (health && sick_health_probes > 0) { --sick_health_probes; r.score *= 0.5; } - if (stall_core_from >= 0 && core >= stall_core_from) { r.score *= 0.03; return r; } // verdict stays Stable - if (seconds == 20 && power == noisy_power_pct) r.score *= 0.9; - if (seconds == 30 && std::find(confirm_fail_core.begin(), confirm_fail_core.end(), core) != confirm_fail_core.end()) { - r.verdict = Verdict::WrongResult; - return r; - } - if (stock_unstable || core > core_edge || mem > mem_edge) r.verdict = Verdict::WrongResult; - else if (seconds == 300 && soak_failures > 0) { --soak_failures; r.verdict = Verdict::WrongResult; } - else if (r.peak_temp_c > max_temp) r.verdict = Verdict::TooHot; - return r; - }; - } -}; - -struct Run { - FakeCard card; - std::vector log; - int abort_after_probes = -1; - bool no_rest = false; // the application gives the search no way to wait - std::optional stock; // the measurement at stock that went before the run, when there was one - std::string rig = "card-a / 617.42"; // the card and driver the journal's memory edges are bound to - std::vector> steps; // what the search said it was doing, and for how long - OptimizeResult go(Preset preset, GpuControl gpu) { - Journal j(card.journal, [this](const std::string& l) { - if (l.find("\"complete\"") != std::string::npos && card.completes_ok >= 0 && card.completes_ok-- == 0) - return false; - if (l.find("\"begin\"") != std::string::npos) card.events.push_back("begin"); - card.journal.push_back(l); - return true; - }, rig); - OptimizeIo io; - io.probe = card.probe(); - io.aborted = [this] { return card.aborted_now || (abort_after_probes >= 0 && card.probes >= abort_after_probes); }; - io.log = [this](const std::string& m) { log.push_back(m); }; - if (!no_rest) - io.rest = [this](double) { - ++card.rests; - card.events.push_back("rest"); - if (card.abort_during_rest || card.rests == card.abort_on_rest) { card.aborted_now = true; return false; } - return true; - }; - if (card.with_prepare) - io.prepare_load = [this] { - ++card.prepares; - card.events.push_back("prepare"); - if (card.entry_open()) ++card.prepares_with_entry_open; - if (card.abort_after_prepare) card.aborted_now = true; - if (card.prepare_fails > 0) { --card.prepare_fails; return false; } - return true; - }; - if (card.bw_curve) - io.bandwidth = [this]() -> std::optional { - const int nth = card.bw_readings_at[card.mem]++; - if (card.mem < card.max_mem_seen) { // the second pass - ++card.bw_again_measurements; - card.max_again_mem = std::max(card.max_again_mem, card.mem); - card.bw_unsettled = false; - if (card.mem >= card.bw_again_loses_device_from) { card.driver_reset(); return std::nullopt; } - const auto& noise = card.bw_again_noise ? card.bw_again_noise : card.bw_noise; - return card.bw_curve(card.mem) * (noise ? noise(card.mem, nth) : 1.0); - } - ++card.bw_measurements; - card.bw_unsettled = card.mem == card.bw_unsettled_at_mem || (card.mem == card.bw_unsettled_once_at_mem && nth == 0); - if (card.bw_unsettled) return std::nullopt; - if (card.bw_loses_device) { card.driver_reset(); return std::nullopt; } - if (card.bw_fails_at_zero && card.mem == 0) return std::nullopt; - if (card.mem == card.bw_fails_once_at_mem) { - card.bw_fails_once_at_mem = -1; - card.driver_reset(); - return std::nullopt; - } - if (card.bw_fails) return std::nullopt; - return card.bw_curve(card.mem) * (card.bw_noise ? card.bw_noise(card.mem, nth) : 1.0); - }; - if (card.bw_curve) io.bandwidth_unsettled = [this] { return card.bw_unsettled; }; - io.measuring = [this](const std::string& what, double seconds) { steps.emplace_back(what, seconds); }; - return optimize(gpu, objectives_for(preset), j, io, stock); - } - OptimizeResult go(Preset preset) { return go(preset, card.gpu()); } - bool said(const std::string& what, double seconds) const { - return std::find(steps.begin(), steps.end(), std::make_pair(what, seconds)) != steps.end(); - } - bool logged(const std::string& what) const { return count_logged(what) > 0; } - int count_logged(const std::string& what) const { - int n = 0; - for (const auto& m : log) n += m.find(what) != std::string::npos; - return n; - } -}; - -using Events = std::vector; - -int count_events(const FakeCard& card, const std::string& what) { - return static_cast(std::count(card.events.begin(), card.events.end(), what)); -} - -// Everything that happened after the journal entry of the candidate the -// driver reset under was opened: the events after the last "begin" before the -// first "recover". -Events events_after_lost(const FakeCard& card) { - const auto first_recover = std::find(card.events.begin(), card.events.end(), "recover"); - auto from = first_recover; - while (from != card.events.begin() && *(from - 1) != "begin") --from; - if (from == card.events.begin()) return {}; // no candidate was open before the first reconnect - return {from, card.events.end()}; -} - -// The events after the last journal entry of the run was opened. -Events events_after_last_begin(const FakeCard& card) { - auto from = card.events.end(); - while (from != card.events.begin() && *(from - 1) != "begin") --from; - return {from, card.events.end()}; -} - -// The soak entries of a run whose soak has both clocks above stock: the -// `begin` lines that carry both a core and a memory offset. (A soak journals -// only the clocks that are above stock.) -int soak_entries(const FakeCard& card) { - int n = 0; - for (const auto& l : card.journal) - n += l.find("\"begin\"") != std::string::npos && l.find("\"core\":") != std::string::npos && - l.find("\"mem\":") != std::string::npos; - return n; -} - -// The first n of them. -Events first_events_after_lost(const FakeCard& card, std::size_t n) { - auto all = events_after_lost(card); - if (all.size() > n) all.resize(n); - return all; -} - -// The offsets of the journal's `begin` lines that carry `field` and not -// `other`, in the order they were written. -std::vector begins_with_only(const FakeCard& card, const std::string& field, const std::string& other) { - std::vector out; - const std::string key = "\"" + field + "\":"; - for (const auto& l : card.journal) { - if (l.find("\"begin\"") == std::string::npos || l.find("\"" + other + "\":") != std::string::npos) continue; - const auto at = l.find(key); - if (at != std::string::npos) out.push_back(std::stoi(l.substr(at + key.size()))); - } - return out; -} - -// What every run with a reconnect must leave behind: no reconnect was made -// inside an open journal entry, and no entry is left open. -void check_journal_rules(const Run& run) { - CHECK(run.card.recovers_with_entry_open == 0); - CHECK(run.card.count("\"begin\"") == run.card.count("\"complete\"")); - Journal reread(run.card.journal, [](const std::string&) { return true; }); - CHECK(reread.freezes().empty()); -} -} - -// A reset the probe reported is handled by the gate before the next step -// starts: no write is tried on the dead connections first, which would fail -// and count as a second reset event. -void check_reconnected_up_front(const Run& run) { - CHECK(run.logged("the driver was reset; reconnecting")); - CHECK_FALSE(run.logged("trying once more")); - CHECK(run.card.count("SET FAILED") == 0); -} - TEST_CASE("best preset converges to the card's edges and applies the margin") { Run run; const auto r = run.go(Preset::BestOfMyGpu); @@ -679,174 +287,6 @@ TEST_CASE("a soak that runs too hot lowers power, not clocks") { CHECK(r.mem_mhz == 550); } -TEST_CASE("the bandwidth scan ends at the first step that has clearly lost bandwidth") { - int max_sampled = -1; - const MemScanEnd end = bandwidth_scan(0, 1500, 50, INT_MAX, [&](int v) { - max_sampled = std::max(max_sampled, v); - return MemSample{true, v <= 600 ? 500 + v * 0.3 : 680 - (v - 600) * 0.9}; - }); - CHECK(end.why == MemScanEnd::Why::Dropped); - // The best is the middle of three steps, 665 GB/s around the peak at - // +600: +650 is 4.5 % below it, +700 is 11 %. It was stable, so it passed. - CHECK(end.top == 700); - CHECK(max_sampled == 700); -} - -TEST_CASE("a single low bandwidth reading does not end the scan; three do") { - // A step that is further from the best than readings scatter is read - // kBandwidthReadings times and its middle reading counts. One dip (measured - // on the 4070: a reading at half speed, the memory clock still in a lower - // P-state) ends nothing; a drop that is there every time does. - std::map calls; - MemScanEnd end = bandwidth_scan(0, 1500, 50, INT_MAX, [&](int v) { - const bool dip = v == 300 && calls[v] == 0; - ++calls[v]; - return MemSample{true, dip ? 250.0 : 500 + v * 0.1}; - }); - CHECK(end.why == MemScanEnd::Why::Limit); - CHECK(end.top == 1500); - CHECK(calls[250] == 1); // nothing in doubt: one reading - CHECK(calls[300] == 3); - CHECK(calls[350] == 1); - - // Two low readings of three are the middle one. - calls.clear(); - end = bandwidth_scan(0, 1500, 50, INT_MAX, [&](int v) { - const int nth = calls[v]++; - return MemSample{true, v == 300 && nth < 2 ? 250.0 : 500 + v * 0.1}; - }); - CHECK(end.why == MemScanEnd::Why::Dropped); - CHECK(end.top == 300); - CHECK(calls[300] == 3); - CHECK(calls.count(350) == 0); - - // A step that stops being stable while it is read again ends the scan below it. - calls.clear(); - end = bandwidth_scan(0, 1500, 50, INT_MAX, [&](int v) { - const int nth = calls[v]++; - return MemSample{!(v == 300 && nth == 1), v == 300 ? 250.0 : 500 + v * 0.1}; - }); - CHECK(end.why == MemScanEnd::Why::Failed); - CHECK(end.top == 250); - CHECK(calls[300] == 2); -} - -TEST_CASE("a single high bandwidth reading does not become the best the scan is held against") { - // The run that saved +100 MHz on a card that runs every game at +1500: one - // reading came out far above its neighbours, and the steps after it were - // "a drop". - std::map calls; - const MemScanEnd end = bandwidth_scan(0, 1500, 50, INT_MAX, [&](int v) { - const bool spike = v == 200 && calls[v] == 0; - ++calls[v]; - return MemSample{true, (500 + v * 0.1) * (spike ? 1.08 : 1.0)}; - }); - CHECK(end.why == MemScanEnd::Why::Limit); - CHECK(end.top == 1500); - for (const auto& [v, n] : calls) CHECK(n == 1); // and no step after it was in doubt - - // Two high steps in a row are the middle of three, and so a best: what - // comes after them is that far below it, read three times, and ends the scan. - const MemScanEnd two = bandwidth_scan(0, 1500, 50, INT_MAX, [&](int v) { - return MemSample{true, (500 + v * 0.1) * (v == 200 || v == 250 ? 1.08 : 1.0)}; - }); - CHECK(two.why == MemScanEnd::Why::Dropped); - CHECK(two.top == 300); -} - -TEST_CASE("the bandwidth scan ends below the first unstable step") { - int max_sampled = -1; - const MemScanEnd end = bandwidth_scan(0, 1500, 50, INT_MAX, [&](int v) { - max_sampled = std::max(max_sampled, v); - return MemSample{v <= 400, 500 + v * 0.1}; - }); - CHECK(end.why == MemScanEnd::Why::Failed); - CHECK(end.top == 400); - CHECK(max_sampled == 450); -} - -TEST_CASE("the bandwidth scan samples lo and respects the ceiling") { - std::vector sampled; - const MemScanEnd end = bandwidth_scan(0, 1500, 50, 300, [&](int v) { - sampled.push_back(v); - return MemSample{true, 500 + v * 0.1}; - }); - REQUIRE_FALSE(sampled.empty()); - CHECK(sampled.front() == 0); - CHECK(sampled.back() == 250); - CHECK(end.top == 250); - CHECK(end.why == MemScanEnd::Why::Limit); - const MemScanEnd none = bandwidth_scan(0, 1500, 50, 300, [](int) { return MemSample{false, 0}; }); - CHECK(none.top == 0); - CHECK(none.why == MemScanEnd::Why::Failed); -} - -TEST_CASE("the second pass begins four steps below the highest step the scan passed") { - using Why = MemScanEnd::Why; - // The step above +2500 failed: +2500 passed, so +2300. - CHECK(second_pass_top({2500, Why::Failed}, 0, 50, INT_MAX) == 2300); - // +1000 passed and had lost bandwidth: +800. - CHECK(second_pass_top({1000, Why::Dropped}, 0, 50, INT_MAX) == 800); - // The driver's range, or the ceiling of a freeze: nothing failed, and the - // margin is kept all the same. What is above was never tried. - CHECK(second_pass_top({3000, Why::Limit}, 0, 50, INT_MAX) == 2800); - // Never below lo, and a scan that passed nothing reads nothing above it. - CHECK(second_pass_top({150, Why::Failed}, 0, 50, INT_MAX) == 0); - CHECK(second_pass_top({0, Why::Failed}, 0, 50, INT_MAX) == 0); -} - -TEST_CASE("a scan held below a known edge counts the margin from that edge, once") { - using Why = MemScanEnd::Why; - // The run that found the edge at +2550 read from +2300. The run after it is - // held at +2350 and reads from +2300 as well, not from +2150. - CHECK(second_pass_top({2500, Why::Failed}, 0, 50, INT_MAX) == 2300); - CHECK(second_pass_top({2350, Why::Limit}, 0, 50, 2550) == 2300); - // A step that failed below the known edge counts, not the edge. - CHECK(second_pass_top({2000, Why::Failed}, 0, 50, 2550) == 1800); - CHECK(second_pass_top({2000, Why::Dropped}, 0, 50, 2550) == 1800); - // An edge that is no higher than where the scan got changes nothing. - CHECK(second_pass_top({2350, Why::Limit}, 0, 50, 2400) == 2150); -} - -TEST_CASE("the second pass goes down from the top and keeps the lowest offset within the tie") { - // A curve that still rises at the top: 0.4 % a step. - std::vector sampled; - int r = bandwidth_peak_below(2300, 0, 50, [&](int v) { - sampled.push_back(v); - return MemSample{true, 434.0 * (1 + 0.004 * v / 50)}; - }); - CHECK(r == 2200); - CHECK(sampled == std::vector{2300, 2250, 2200, 2150}); - - // A peak below the top: the readings rise on the way down, then fall. - sampled.clear(); - r = bandwidth_peak_below(1200, 0, 50, [&](int v) { - sampled.push_back(v); - return MemSample{true, v <= 900 ? 500 + v * 0.3 : 770 - (v - 900) * 0.6}; - }); - CHECK(r == 900); - CHECK(sampled.front() == 1200); - CHECK(sampled.back() == 850); // 1.9 % below the peak: the pass ends - - // A flat curve keeps the lowest offset there is. - CHECK(bandwidth_peak_below(400, 0, 50, [](int v) { return MemSample{true, 500.0 + ((v / 50) % 2)}; }) == 0); - // Readings that scatter by twice what they do on a quiet 4070 leave a flat - // curve at the bottom. - CHECK(bandwidth_peak_below(400, 0, 50, [](int v) { return MemSample{true, 500.0 * (1 + (((v / 50) % 3) - 1) * 0.004)}; }) == 0); -} - -TEST_CASE("the second pass keeps what it has when a step is not stable") { - // Not one sample: the top it was given. - CHECK(bandwidth_peak_below(2300, 0, 50, [](int) { return MemSample{false, 0}; }) == 2300); - // One sample, then none: that one. - int n = 0; - CHECK(bandwidth_peak_below(2300, 0, 50, [&](int v) { return MemSample{n++ == 0, 500.0 + v * 0.1}; }) == 2300); - // Never below lo. - std::vector sampled; - CHECK(bandwidth_peak_below(100, 0, 50, [&](int v) { sampled.push_back(v); return MemSample{true, 500.0}; }) == 0); - CHECK(sampled == std::vector{100, 50, 0}); -} - TEST_CASE("confirm_edge keeps a holding edge and steps down otherwise") { int calls = 0; CHECK(confirm_edge(150, 0, 15, 3, [&](int) { ++calls; return true; }) == 150); @@ -1001,1812 +441,12 @@ TEST_CASE("a power probe stopped while it runs restores stock power") { CHECK(run.card.journal.empty()); } -TEST_CASE("climb_to_edge climbs in strides, then refines on the step grid") { - std::vector probed; - const int r = climb_to_edge(0, 300, 15, 60, INT_MAX, [&](int v) { probed.push_back(v); return v <= 150; }); - CHECK(r == 150); - CHECK(probed == std::vector{60, 120, 180, 150, 165}); -} - -TEST_CASE("climb_to_edge finds an edge between strides and between grid points") { - CHECK(climb_to_edge(0, 300, 15, 60, INT_MAX, [](int v) { return v <= 100; }) == 90); - CHECK(climb_to_edge(0, 300, 15, 60, INT_MAX, [](int v) { return v <= 120; }) == 120); - CHECK(climb_to_edge(0, 300, 15, 60, INT_MAX, [](int v) { return v <= 15; }) == 15); -} - -TEST_CASE("climb_to_edge never probes lo, and returns lo when nothing above holds") { - std::vector probed; - const int r = climb_to_edge(0, 300, 15, 60, INT_MAX, [&](int v) { probed.push_back(v); return false; }); - CHECK(r == 0); - CHECK(probed == std::vector{60, 30, 15}); -} - -TEST_CASE("climb_to_edge never exceeds hi, even when hi is off the grid") { - int max_probed = 0; - const int r = climb_to_edge(0, 1000, 15, 60, INT_MAX, [&](int v) { max_probed = std::max(max_probed, v); return true; }); - CHECK(r == 990); // the highest grid point at or below +1000 - CHECK(max_probed == 990); - CHECK(climb_to_edge(0, 300, 15, 60, INT_MAX, [](int) { return true; }) == 300); -} - -TEST_CASE("climb_to_edge stays below the journal ceiling") { - int max_probed = 0; - const int r = climb_to_edge(0, 300, 15, 60, 120, [&](int v) { max_probed = std::max(max_probed, v); return true; }); - CHECK(r == 105); - CHECK(max_probed == 105); - int calls = 0; - CHECK(climb_to_edge(0, 300, 15, 60, 0, [&](int) { ++calls; return true; }) == 0); - CHECK(calls == 0); -} - -TEST_CASE("climb_to_edge never probes a value twice, and never above the first failure") { - // A marginal clock can pass one probe and fail the next. A value that - // failed is not given a second chance, and nothing above it is tried. - std::map calls; - int first_failure = INT_MAX; - climb_to_edge(0, 600, 15, 60, INT_MAX, [&](int v) { - ++calls[v]; - CHECK(v < first_failure); - const bool ok = v <= 260; - if (!ok) first_failure = std::min(first_failure, v); - return ok; - }); - for (const auto& [v, n] : calls) CHECK(n == 1); -} - -TEST_CASE("the core search probes upward from stock, never from the middle of the range") { - Run run; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.core_max_stable == 150); - CHECK(run.card.max_core_seen == 165); // one step past the edge, not +225 or +300 -} - -TEST_CASE("plausible_range accepts a normal range and rejects each kind of nonsense") { - CHECK(plausible_range({-500, 1000}, 0, 2000)); - CHECK(plausible_range({0, 1000}, 0, 2000)); // a card that allows no negative offset - CHECK(plausible_range({-500, 1000}, 210, 2000)); // a tune is applied - CHECK_FALSE(plausible_range({100, 1000}, 0, 2000)); // minimum above zero - CHECK_FALSE(plausible_range({-500, 0}, 0, 2000)); // maximum not above zero - CHECK_FALSE(plausible_range({0, 0}, 0, 2000)); // an empty buffer - CHECK_FALSE(plausible_range({-500, 1000}, 1200, 2000)); // the applied offset is outside it - CHECK_FALSE(plausible_range({-500, 1000}, -600, 2000)); - CHECK_FALSE(plausible_range({-500000, 1000000}, 0, 2000)); // kHz read as MHz - CHECK(plausible_range({-500, 2000}, 0, 2000)); // the sanity limit itself is allowed - CHECK_FALSE(plausible_range({-500, 2001}, 0, 2000)); -} - -TEST_CASE("search_bounds falls back when the card reports nothing usable") { - GpuControl none; - CHECK(search_bounds(none).core_max_mhz == 300); - CHECK(search_bounds(none).mem_max_mhz == 1500); - CHECK_FALSE(search_bounds(none).core_from_card); - - GpuControl no_read_back; // a range, but no way to check the applied offset against it - no_read_back.clock_offset_range_mhz = [] { return std::optional(ClockOffsetRanges{{-500, 1000}, {-1000, 3000}}); }; - CHECK(search_bounds(no_read_back).core_max_mhz == 300); - - GpuControl read_fails = no_read_back; - read_fails.read_applied = []() -> std::optional { return std::nullopt; }; - CHECK(search_bounds(read_fails).core_max_mhz == 300); - - GpuControl range_fails; - range_fails.clock_offset_range_mhz = []() -> std::optional { return std::nullopt; }; - range_fails.read_applied = [] { return std::optional(AppliedState{}); }; - CHECK(search_bounds(range_fails).mem_max_mhz == 1500); -} - -TEST_CASE("search_bounds takes core and memory from the card independently") { - GpuControl g; - g.read_applied = [] { return std::optional(AppliedState{}); }; - g.clock_offset_range_mhz = [] { return std::optional(ClockOffsetRanges{{-500, 1000}, {-1000, 3000}}); }; - const SearchBounds both = search_bounds(g); - CHECK(both.core_max_mhz == 1000); - CHECK(both.mem_max_mhz == 3000); - CHECK(both.core_from_card); - CHECK(both.mem_from_card); - - g.clock_offset_range_mhz = [] { return std::optional(ClockOffsetRanges{{-500, 1000}, {50, 9000}}); }; - const SearchBounds core_only = search_bounds(g); - CHECK(core_only.core_max_mhz == 1000); - CHECK(core_only.mem_max_mhz == 1500); - CHECK_FALSE(core_only.mem_from_card); -} - -TEST_CASE("a card that holds more than the old cap is searched to its own edge") { - Run run; - run.card.ranges = ClockOffsetRanges{{-500, 1000}, {-1000, 3000}}; - run.card.core_edge = 450; - run.card.mem_edge = 2000; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.core_max_stable == 450); - CHECK(r.core_confirmed == 450); - CHECK(r.core_mhz == 315); // 70 % of 450 - CHECK(r.mem_max_stable == 2000); - CHECK(r.mem_mhz == 1400); // 70 % of 2000 - CHECK(run.card.max_core_seen == 465); // one step past the edge - CHECK(run.card.max_mem_seen == 2050); - bool told = false; - for (const auto& m : run.log) told |= m.find("core range: up to +1000 MHz (reported by the card)") != std::string::npos; - CHECK(told); -} - -TEST_CASE("a card stable to the top of its reported range stops at that range") { - Run run; - run.card.ranges = ClockOffsetRanges{{-500, 400}, {-1000, 1000}}; - run.card.core_edge = 5000; - run.card.mem_edge = 5000; - const auto r = run.go(Preset::MaxPerformance); - REQUIRE(r.ok); - CHECK(r.core_max_stable == 390); // the highest 15 MHz grid point at or below +400 - CHECK(run.card.max_core_seen == 390); - CHECK(r.mem_max_stable == 1000); - CHECK(run.card.max_mem_seen == 1000); -} - -TEST_CASE("an implausible reported range leaves the built-in limit in place and says so") { - Run run; - run.card.ranges = ClockOffsetRanges{{100, 1000}, {-1000, 3000}}; // core minimum above zero - run.card.core_edge = 450; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.core_max_stable == 300); - CHECK(run.card.max_core_seen == 300); - bool told = false; - for (const auto& m : run.log) told |= m.find("core range: up to +300 MHz (built-in limit") != std::string::npos; - CHECK(told); -} - -TEST_CASE("a failing range read leaves the built-in limits in place") { - Run run; - run.card.ranges_read_fails = true; - run.card.core_edge = 450; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.core_max_stable == 300); -} - -TEST_CASE("a probe that passes at a fraction of the baseline score is stalled, not stable") { - // Measured on an RTX 5070: at +480 the card stopped computing, the probe - // found no wrong value and no lost device, and scored 167 against 5869. - Run run; - run.card.stall_core_from = 135; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.core_max_stable == 120); - // Without a reconnect the reset budget does not apply: the stall is - // counted, the confirm probe runs at the edge with no back-off, and the - // log does not speak of a reset. - CHECK(r.driver_resets == 1); - CHECK(r.core_confirmed == 120); - CHECK_FALSE(run.logged("the driver reset")); - bool journaled = false; - for (const auto& l : run.card.journal) journaled |= l.find("STALLED") != std::string::npos; - CHECK(journaled); - bool logged = false; - for (const auto& m : run.log) logged |= m.find("STALLED") != std::string::npos; - CHECK(logged); -} - -TEST_CASE("a low score at a low power limit is not a stall") { - // The quiet preset probes power limits down to the card's minimum; the - // fixture's score at 50 % power is 56 % of the baseline. - Run run; - const auto r = run.go(Preset::Quiet); - REQUIRE(r.ok); - for (const auto& m : run.log) CHECK(m.find("STALLED") == std::string::npos); -} - -TEST_CASE("without a reconnect a driver reset still ends the run cleanly") { - // Today's behaviour, kept for a GpuControl that cannot reconnect. - Run run; - run.card.lost_core_from = 135; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason.find("failed") != std::string::npos); - CHECK_FALSE(r.stock_restored); - Journal reread(run.card.journal, [](const std::string&) { return true; }); - CHECK(reread.freezes().empty()); -} - -TEST_CASE("a driver that does not come back ends the run without opening another journal entry") { - Run run; - run.card.with_recover = true; - run.card.recover_fails = true; - run.card.lost_core_from = 135; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the driver did not come back after a reset"); - CHECK_FALSE(r.stock_restored); - CHECK(run.card.recover_calls == 1); // a reconnect that failed is not tried again on the way out - CHECK(run.card.count("\"begin\"") == run.card.begins_when_recover_failed); // nothing was journaled after the failed reconnect - check_journal_rules(run); -} - -TEST_CASE("without a reconnect a probe without telemetry ends the run at once") { - Run run; - run.card.blind_probes = 1; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "lost telemetry"); -} - -TEST_CASE("a run stopped while the card is stale reconnects before it resets to stock") { - // Probe 32 is core +135, the lost candidate (23 probes up to the memory - // confirm, then core +15 .. +120); the stop request is seen before the - // next candidate (the confirm probe at +60) and before its recovery gate, - // with the old connections still dead. - Run run; - run.card.with_recover = true; - run.card.lost_core_from = 135; - run.abort_after_probes = 32; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "aborted"); - CHECK(run.card.probes == 32); - CHECK(run.logged("core +135 / mem +550: DEVICE LOST")); - CHECK(r.stock_restored); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - CHECK(run.card.power == 100); - CHECK_FALSE(run.card.stale); - CHECK(run.card.recover_calls == 1); - CHECK(run.card.rests == 0); - CHECK(count_events(run.card, "health") == 0); - CHECK(begins_with_only(run.card, "core", "mem") == std::vector{15, 30, 45, 60, 75, 90, 105, 120, 135}); - check_journal_rules(run); -} - -// The probes of a default best-preset run, in order: 1 baseline; 2-5 power -// (85, 105, 115, 120 %); 6-22 memory (+50, +100, ... +850); 23 memory confirm -// (+800); 24-34 core (+15, +30, ... +165); 35 core confirm (+150); 36 soak. -TEST_CASE("the probe order the driver-reset tests rely on") { - Run run; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(run.card.probes == 36); - // Each probe as "seconds:power:core:mem". - std::vector want{"30:100:0:0", "20:85:0:0", "20:105:0:0", "20:115:0:0", "20:120:0:0"}; - for (int mem = 50; mem <= 850; mem += 50) want.push_back("3:115:0:" + std::to_string(mem)); - want.push_back("30:115:0:800"); - for (int core = 15; core <= 165; core += 15) want.push_back("3:115:" + std::to_string(core) + ":550"); - want.push_back("30:115:150:550"); - want.push_back("300:115:105:550"); - REQUIRE(want.size() == 36); - CHECK(run.card.probe_order == want); -} - -TEST_CASE("a stop request during a failing reconnect is reported as aborted") { - Run run; - run.card.with_recover = true; - run.card.recover_fails = true; - run.card.abort_in_recover = true; - run.card.lost_core_from = 135; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "aborted"); - CHECK(run.card.recover_calls == 1); - check_journal_rules(run); -} - -TEST_CASE("a stale connection at the start is reconnected before the first reset") { - Run run; - run.card.with_recover = true; - run.card.stale = true; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(run.card.recover_calls == 1); - CHECK(r.core_max_stable == 150); - check_journal_rules(run); -} - -TEST_CASE("a start that cannot reset to stock even after a reconnect says so") { - Run dead; // the reconnect fails - dead.card.with_recover = true; - dead.card.recover_fails = true; - dead.card.stale = true; - const auto a = dead.go(Preset::BestOfMyGpu); - CHECK_FALSE(a.ok); - CHECK(a.reason == "could not reset to stock"); - CHECK_FALSE(a.stock_restored); - CHECK(dead.logged("reset FAILED")); - CHECK(dead.card.recover_calls == 1); - CHECK(dead.card.probes == 0); - - Run stuck; // the reconnect works, the reset keeps failing - stuck.card.with_recover = true; - stuck.card.reset_fails_from = 0; - const auto b = stuck.go(Preset::BestOfMyGpu); - CHECK_FALSE(b.ok); - CHECK(b.reason == "could not reset to stock"); - CHECK_FALSE(b.stock_restored); - CHECK(stuck.logged("reset FAILED")); - CHECK(stuck.card.recover_calls == 1); - CHECK(stuck.card.probes == 0); -} - -TEST_CASE("memory is searched before the core") { - Run run; - run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - int mem_begins = 0, core_begins = 0, mem_after_core = 0; - for (const auto& l : run.card.journal) { - if (l.find("\"begin\"") == std::string::npos) continue; - const bool has_core = l.find("\"core\":") != std::string::npos; - const bool has_mem = l.find("\"mem\":") != std::string::npos; - if (has_core && !has_mem) ++core_begins; - if (has_mem && !has_core) { - ++mem_begins; - if (core_begins > 0) ++mem_after_core; - } - } - CHECK(mem_begins > 0); - CHECK(core_begins > 0); - CHECK(mem_after_core == 0); -} - -TEST_CASE("the core climb runs with the chosen memory applied") { - Run run; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.mem_mhz == 550); - // 11 core candidates, the core confirm probe and the soak. - CHECK(run.card.mem_during_core_probes.size() == 13); - for (int mem : run.card.mem_during_core_probes) CHECK(mem == r.mem_mhz); -} - -TEST_CASE("the core climb advances one step at a time") { - Run run; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - // The climb, then the one 30 s confirm probe at the edge. - CHECK(begins_with_only(run.card, "core", "mem") == - std::vector{15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 150}); - CHECK(r.core_max_stable == 150); - CHECK(run.card.max_core_seen == 165); -} - -TEST_CASE("the stability-only memory climb advances one step at a time") { - Run run; // no bw_curve: no bandwidth measurement - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - // The climb, then the one 30 s confirm probe at the edge. - CHECK(begins_with_only(run.card, "mem", "core") == - std::vector{50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 800}); - CHECK(r.mem_max_stable == 800); - CHECK(run.card.max_mem_seen == 850); -} - -TEST_CASE("every profile gives today's result on a card with no reset") { - struct Want { Preset preset; int power, core, mem; }; - const Want wants[] = {{Preset::BestOfMyGpu, 115, 105, 550}, {Preset::Quiet, 90, 60, 300}, - {Preset::MaxPerformance, 120, 135, 750}, {Preset::CoolAndEfficient, 85, 0, 0}}; - for (const auto& w : wants) { - Run run; - const auto r = run.go(w.preset); - REQUIRE(r.ok); - CHECK(r.power_pct == w.power); - CHECK(r.core_mhz == w.core); - CHECK(r.mem_mhz == w.mem); - CHECK(run.card.power == w.power); - CHECK(run.card.core == w.core); - CHECK(run.card.mem == w.mem); - } -} - -TEST_CASE("a profile without core tuning and a card without power control still run") { - Run cool; - const auto a = cool.go(Preset::CoolAndEfficient); - REQUIRE(a.ok); - CHECK(cool.card.max_core_seen == 0); - CHECK(cool.card.max_mem_seen == 0); - CHECK(cool.card.journal.empty() == false); // the soak is journaled - - Run fixed; - const auto b = fixed.go(Preset::BestOfMyGpu, fixed.card.gpu(/*with_power=*/false)); - REQUIRE(b.ok); - CHECK(fixed.card.power_probes == 0); - CHECK(b.power_pct == 100); - CHECK(b.core_mhz == 105); - CHECK(b.mem_mhz == 550); -} - -// The recovery gate. Unless stated, the card loses the device at core +135: -// probe 32 of the run, the only driver reset, between the climb and the -// confirm step. -namespace { -Run lost_at_135() { - Run run; - run.card.with_recover = true; - run.card.lost_core_from = 135; - return run; -} -} - -TEST_CASE("after a reset the search reconnects, goes to stock, rests and proves the card is back before the next candidate") { - Run run = lost_at_135(); - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(first_events_after_lost(run.card, 5) == Events{"recover", "stock", "rest", "health", "begin"}); - CHECK(run.card.rests == 1); - CHECK(count_events(run.card, "health") == 1); - CHECK(r.driver_resets == 1); - CHECK(run.card.recovers_with_entry_open == 0); - CHECK(run.logged("resting 20 s before the card is loaded again")); - CHECK(run.logged("checking that the card is back: 5 s at stock")); - CHECK(run.count_logged("health: ") == 1); - CHECK(run.logged("health: STABLE")); - check_journal_rules(run); -} - -TEST_CASE("a card that needs a second rest is accepted") { - Run run = lost_at_135(); - run.card.sick_health_probes = 1; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(run.card.rests == 2); - CHECK(count_events(run.card, "health") == 2); - // No candidate between the two health probes. - CHECK(first_events_after_lost(run.card, 7) == Events{"recover", "stock", "rest", "health", "rest", "health", "begin"}); - CHECK(r.driver_resets == 1); - CHECK(run.card.recover_calls == 1); - check_journal_rules(run); -} - -TEST_CASE("a card that never recovers ends the run at stock without opening another entry") { - Run run = lost_at_135(); - run.card.sick_health_probes = 99; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the card did not recover after a driver reset"); - CHECK(run.card.rests == 3); - CHECK(count_events(run.card, "health") == 3); - // Three rests and probes, no entry after the lost candidate's, and one - // reset to stock on the way out without another reconnect. - CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "health", "rest", "health", "rest", "health", "stock"}); - CHECK(r.stock_restored); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - CHECK(run.card.power == 100); - CHECK(r.driver_resets == 1); - // The connections were good on the way out: no reconnect, and no line that says one was made. - CHECK_FALSE(run.logged("reconnecting to the driver to restore stock")); - check_journal_rules(run); -} - -TEST_CASE("a driver reset during the health probe ends the run at once") { - Run run = lost_at_135(); - run.card.health_verdict = Verdict::DeviceLost; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the card was not usable when checked after a driver reset (DEVICE LOST)"); - CHECK(r.driver_resets == 2); - CHECK(count_events(run.card, "health") == 1); - CHECK(run.card.rests == 1); - // After the health probe: no entry, no rest, no second health probe; one - // reconnect and one reset to stock on the way out. - CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "health", "recover", "stock"}); - CHECK(run.card.recover_calls == 2); - CHECK(r.stock_restored); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - CHECK(run.card.power == 100); - CHECK_FALSE(run.card.stale); - CHECK(run.logged("health: DEVICE LOST")); - // The reconnect on the way out can take half a minute: the log says so first. - CHECK(run.count_logged("reconnecting to the driver to restore stock") == 1); - check_journal_rules(run); -} - -TEST_CASE("the stall floor of every probe is a quarter of the baseline score, except the baseline and the health probe") { - Run run = lost_at_135(); - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - REQUIRE(r.baseline.score > 0); - const double floor = kStalledScore * r.baseline.score; - const auto& probes = run.card.probe_floors; - REQUIRE(probes.size() == static_cast(run.card.probes)); - // The baseline has nothing to be measured against yet. - CHECK(probes.front().first == 30); - CHECK(probes.front().second == 0); - int power = 0, clock = 0, confirm = 0, soak = 0, health = 0; - for (std::size_t i = 1; i < probes.size(); ++i) { - const double seconds = probes[i].first, stall_below = probes[i].second; - CAPTURE(i); - CAPTURE(seconds); - if (seconds == 5) { - // The health probe is judged on its whole run, after it ends. - ++health; - CHECK(stall_below == 0); - continue; - } - if (seconds == 20) ++power; - else if (seconds == 3) ++clock; - else if (seconds == 30) ++confirm; - else if (seconds == 300) ++soak; - else FAIL_CHECK("a probe of an unexpected length"); - CHECK(stall_below == floor); - } - // Every kind of probe ran: 4 power steps, 17 memory and 9 core candidates, - // the memory and the core confirm probe, one health probe and the soak. - CHECK(power == 4); - CHECK(clock == 26); - CHECK(confirm == 2); - CHECK(health == 1); - CHECK(soak == 1); -} - -TEST_CASE("a blind health probe ends the run at once") { - Run run = lost_at_135(); - run.card.health_verdict = Verdict::NoTelemetry; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the card was not usable when checked after a driver reset (NO TELEMETRY)"); - CHECK(r.driver_resets == 2); - CHECK(count_events(run.card, "health") == 1); - CHECK(run.card.rests == 1); - CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "health", "recover", "stock"}); - CHECK(r.stock_restored); - check_journal_rules(run); -} - -TEST_CASE("a health probe that computes almost nothing ends the run at once") { - Run run = lost_at_135(); - run.card.health_stalls = true; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the card computed almost nothing after a driver reset"); - CHECK(r.driver_resets == 2); - CHECK(count_events(run.card, "health") == 1); - CHECK(run.card.rests == 1); - CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "health", "recover", "stock"}); - CHECK(run.logged("health: STALLED")); - CHECK(r.stock_restored); - CHECK(run.card.core == 0); - check_journal_rules(run); -} - -TEST_CASE("a health probe that only computes wrong or runs hot is retried, not counted") { - for (const Verdict v : {Verdict::WrongResult, Verdict::TooHot}) { - Run run = lost_at_135(); - run.card.health_verdict = v; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 1); - CHECK(count_events(run.card, "health") == 2); - CHECK(run.card.rests == 2); - CHECK(first_events_after_lost(run.card, 7) == Events{"recover", "stock", "rest", "health", "rest", "health", "begin"}); - CHECK(run.card.recover_calls == 1); - check_journal_rules(run); - } -} - -TEST_CASE("a stock write that fails while recovering ends the run without a rest or a health probe") { - Run run = lost_at_135(); - run.card.stock_write_fails_after_recover = true; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "could not set the card to stock while recovering"); - CHECK(run.card.rests == 0); - CHECK(count_events(run.card, "health") == 0); - // The failed write pushes nothing; the way out reconnects once and resets. - CHECK(events_after_lost(run.card) == Events{"recover", "recover", "stock"}); - CHECK(r.driver_resets == 1); - CHECK(r.stock_restored); - check_journal_rules(run); -} - -TEST_CASE("a driver that does not come back is not asked twice") { - Run run = lost_at_135(); - run.card.recover_fails = true; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the driver did not come back after a reset"); - CHECK(run.card.recover_calls == 1); - CHECK(run.card.rests == 0); - CHECK(count_events(run.card, "health") == 0); - CHECK(events_after_lost(run.card) == Events{"recover"}); - CHECK_FALSE(r.stock_restored); - CHECK(r.driver_resets == 1); - check_journal_rules(run); -} - -TEST_CASE("an abort while resting ends as aborted") { - Run run = lost_at_135(); - run.card.abort_during_rest = true; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "aborted"); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - CHECK(run.card.power == 100); - CHECK(count_events(run.card, "health") == 0); - CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "stock"}); - CHECK(r.stock_restored); - check_journal_rules(run); -} - -namespace { -Run lost_at_135_with_prepare() { - Run run = lost_at_135(); - run.card.with_prepare = true; - return run; -} -} - -TEST_CASE("the load is prepared after the rest and before the health probe") { - Run run = lost_at_135_with_prepare(); - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(first_events_after_lost(run.card, 6) == Events{"recover", "stock", "rest", "prepare", "health", "begin"}); - CHECK(run.card.prepares == 1); - CHECK(run.card.prepares_with_entry_open == 0); - CHECK(r.driver_resets == 1); - CHECK(run.logged("preparing the stress load")); - check_journal_rules(run); -} - -TEST_CASE("the load is prepared at no other time") { - Run run; - run.card.with_recover = true; - run.card.with_prepare = true; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(run.card.prepares == 0); -} - -TEST_CASE("a slow health probe does not rebuild the load") { - Run run = lost_at_135_with_prepare(); - run.card.sick_health_probes = 1; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(first_events_after_lost(run.card, 8) == - Events{"recover", "stock", "rest", "prepare", "health", "rest", "health", "begin"}); - CHECK(run.card.prepares == 1); -} - -TEST_CASE("a preparation that fails once costs a rest, not a probe") { - Run run = lost_at_135_with_prepare(); - run.card.prepare_fails = 1; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(first_events_after_lost(run.card, 8) == - Events{"recover", "stock", "rest", "prepare", "rest", "prepare", "health", "begin"}); - CHECK(count_events(run.card, "health") == 1); - CHECK(r.driver_resets == 1); - CHECK(run.logged("the stress load is not ready yet")); - check_journal_rules(run); -} - -TEST_CASE("a load that cannot be prepared ends the run without a health probe") { - Run run = lost_at_135_with_prepare(); - run.card.prepare_fails = 99; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the card did not recover after a driver reset"); - CHECK(events_after_lost(run.card) == - Events{"recover", "stock", "rest", "prepare", "rest", "prepare", "rest", "prepare", "stock"}); - CHECK_FALSE(run.logged("checking that the card is back")); - CHECK(r.stock_restored); - CHECK(run.card.core == 0); - CHECK(r.driver_resets == 1); - Journal reread(run.card.journal, [](const std::string&) { return true; }); - CHECK(reread.freezes().empty()); -} - -TEST_CASE("a stop request during the preparation ends the run as aborted") { - Run run = lost_at_135_with_prepare(); - run.card.abort_after_prepare = true; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "aborted"); - CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "prepare", "stock"}); - CHECK(r.stock_restored); - CHECK(run.card.core == 0); -} - -TEST_CASE("the stale-start rest does not prepare the load") { - Run run; - run.card.with_recover = true; - run.card.with_prepare = true; - run.card.stale = true; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(run.card.prepares == 0); - REQUIRE(run.card.events.size() >= 3); - CHECK(Events(run.card.events.begin(), run.card.events.begin() + 3) == Events{"recover", "stock", "rest"}); -} - -TEST_CASE("without a preparation callback the gate is unchanged") { - Run run = lost_at_135(); - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(first_events_after_lost(run.card, 4) == Events{"recover", "stock", "rest", "health"}); - CHECK(run.card.prepares == 0); -} - -TEST_CASE("without a rest callback failed preparations follow each other") { - Run run = lost_at_135_with_prepare(); - run.no_rest = true; - run.card.prepare_fails = 99; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the card did not recover after a driver reset"); - CHECK(run.card.prepares == 3); - CHECK(run.card.rests == 0); -} - -TEST_CASE("the health probe is not journaled") { - Run run = lost_at_135(); - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(count_events(run.card, "health") == 1); - // Every clock candidate logs "core +C / mem +M: ...", every soak attempt - // "soak: 300 s at ...": one journal entry each, and none for the health probe. - const int entries = run.count_logged(" / mem +") + run.count_logged("soak: 300 s at"); - CHECK(entries > 0); - CHECK(run.card.count("\"begin\"") == entries); - CHECK(run.card.count("\"complete\"") == entries); -} - -TEST_CASE("without a rest callback the gate still proves the card is back") { - Run run = lost_at_135(); - run.no_rest = true; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(count_events(run.card, "health") == 1); - CHECK(run.card.rests == 0); - CHECK(first_events_after_lost(run.card, 4) == Events{"recover", "stock", "health", "begin"}); - CHECK_FALSE(run.logged("resting")); // nothing waited, so the log does not say it did - check_journal_rules(run); -} - -TEST_CASE("a connection that is stale at the start gets a rest before the baseline") { - Run run; - run.card.with_recover = true; - run.card.stale = true; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - // The first reset fails on the stale connection and pushes nothing. - REQUIRE(run.card.events.size() >= 4); - CHECK(Events(run.card.events.begin(), run.card.events.begin() + 4) == Events{"recover", "stock", "rest", "begin"}); - CHECK(run.card.rests == 1); - CHECK(run.logged("resting 20 s before the card is loaded again")); - CHECK(count_events(run.card, "health") == 0); - CHECK(r.driver_resets == 0); - CHECK(run.card.probes == 36); // the run of a card with no reset - check_journal_rules(run); -} - -TEST_CASE("an abort while resting at the start ends before the baseline") { - Run run; - run.card.with_recover = true; - run.card.stale = true; - run.card.abort_during_rest = true; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "aborted"); - CHECK(run.card.probes == 0); - CHECK(r.stock_restored); - CHECK(run.card.recover_calls == 1); -} - -TEST_CASE("without a reconnect nothing changes") { - Run run; - run.card.lost_core_from = 135; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason.find("failed") != std::string::npos); - CHECK_FALSE(r.stock_restored); - CHECK(run.card.rests == 0); - CHECK(count_events(run.card, "health") == 0); - CHECK(count_events(run.card, "recover") == 0); - Journal reread(run.card.journal, [](const std::string&) { return true; }); - CHECK(reread.freezes().empty()); -} - -// The reset budget. A reset event is a probe that ends DEVICE LOST, STALLED or -// NO TELEMETRY, a write that fails, or a bandwidth measurement that fails above -// memory +0. The first one ends exploring; the second one ends the run. -namespace { -Run recovering() { - Run run; - run.card.with_recover = true; - return run; -} - -// from, from + step, ... to, followed by `then`. -std::vector climb(int from, int to, int step, std::vector then = {}) { - std::vector out; - for (int v = from; v <= to; v += step) out.push_back(v); - out.insert(out.end(), then.begin(), then.end()); - return out; -} - -std::vector core_begins(const FakeCard& card) { return begins_with_only(card, "core", "mem"); } -std::vector mem_begins(const FakeCard& card) { return begins_with_only(card, "mem", "core"); } -// The `begin` lines that carry neither clock: the +0 sample of the bandwidth -// scan, and a soak with both clocks at stock. -int clockless_begins(const FakeCard& card) { - int n = 0; - for (const auto& l : card.journal) - n += l.find("\"begin\"") != std::string::npos && l.find("\"core\":") == std::string::npos && - l.find("\"mem\":") == std::string::npos; - return n; -} - -// Nothing that loads the card or waits for it, and no journal entry. -void check_nothing_started(const Events& events) { - for (const auto& e : events) { - CHECK(e != "rest"); - CHECK(e != "health"); - CHECK(e != "begin"); - } -} -} - -TEST_CASE("a reset in the core climb ends the climb at the last value that passed") { - for (const bool stall : {false, true}) { - CAPTURE(stall); - Run run = recovering(); - (stall ? run.card.stall_core_from : run.card.lost_core_from) = 135; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 1); - CHECK(r.core_max_stable == 120); - CHECK(run.card.max_core_seen == 135); // nothing above the candidate that reset the driver was tried - // The climb stops at +135; the first entry after it is the confirm - // probe, four steps below +120. - CHECK(core_begins(run.card) == climb(15, 135, 15, {60})); - CHECK(r.core_confirmed == 60); - CHECK(r.core_mhz == 30); - CHECK(run.logged(stall ? "core +135 / mem +550: STALLED" : "core +135 / mem +550: DEVICE LOST")); - CHECK(run.logged("core: the driver reset at +135; using +120, the last value that passed")); - CHECK(run.logged("confirm core +60 / mem +550: STABLE")); - CHECK(first_events_after_lost(run.card, 5) == Events{"recover", "stock", "rest", "health", "begin"}); - CHECK(run.card.recover_calls == 1); - CHECK_FALSE(run.card.stale); - CHECK(run.card.core == r.core_mhz); // the result is really applied - CHECK(run.card.mem == 550); - CHECK(run.card.power == 115); - check_reconnected_up_front(run); - check_journal_rules(run); - } -} - -TEST_CASE("a reset is never stored as a ceiling") { - Run first = recovering(); - first.card.lost_core_from = 135; - const auto a = first.go(Preset::BestOfMyGpu); - REQUIRE(a.ok); - CHECK(a.core_max_stable == 120); - Journal reread(first.card.journal, [](const std::string&) { return true; }); - CHECK(reread.freezes().empty()); - CHECK(reread.ceilings().core_mhz == INT_MAX); - CHECK(reread.ceilings().mem_mhz == INT_MAX); - - // The next run on the same journal, with nothing resetting the driver. - Run second = recovering(); - second.card.journal = first.card.journal; - const auto b = second.go(Preset::BestOfMyGpu); - REQUIRE(b.ok); - CHECK(b.core_max_stable == 150); - CHECK(b.driver_resets == 0); -} - -TEST_CASE("a second reset in a confirm probe ends the run and starts nothing") { - Run run = recovering(); - run.card.lost_core_from = 135; - run.card.lost_on_first_core_confirm = true; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the driver reset twice"); - CHECK(r.driver_resets == 2); - CHECK(run.logged("confirm core +60 / mem +550: DEVICE LOST")); - CHECK(core_begins(run.card) == climb(15, 135, 15, {60})); - // After the lost confirm probe: one reconnect and one reset to stock on the way out. - check_nothing_started(events_after_last_begin(run.card)); - CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); - CHECK(run.card.rests == 1); - CHECK(count_events(run.card, "health") == 1); - // 23 probes up to the memory confirm, core +15 .. +135 (24-32), the health - // probe (33), the lost confirm probe (34), and none after it. - CHECK(run.card.probes == 34); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - CHECK(run.card.power == 100); - CHECK(r.stock_restored); - REQUIRE_FALSE(run.card.journal.empty()); - CHECK(run.card.journal.back().find("DEVICE LOST") != std::string::npos); - check_journal_rules(run); -} - -TEST_CASE("a second reset in the memory confirm probe ends the run and starts nothing") { - Run run = recovering(); - run.card.lost_mem_from = 300; // the first event, in the memory climb - run.card.lost_on_first_mem_confirm = true; // the second, in the confirm probe at +50 - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the driver reset twice"); - CHECK(r.driver_resets == 2); - CHECK(run.logged("core +0 / mem +300: DEVICE LOST")); - CHECK(run.logged("confirm core +0 / mem +50: DEVICE LOST")); - // The climb to +300, then the one confirm entry four steps below +250. - CHECK(mem_begins(run.card) == climb(50, 300, 50, {50})); - CHECK(core_begins(run.card).empty()); - check_nothing_started(events_after_last_begin(run.card)); - CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); - CHECK(run.card.rests == 1); - CHECK(count_events(run.card, "health") == 1); - // The baseline (1), four power probes (2-5), memory +50 .. +300 (6-11), the - // health probe (12), the lost confirm probe (13), and none after it. - CHECK(run.card.probes == 13); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - CHECK(run.card.power == 100); - CHECK(r.stock_restored); - CHECK_FALSE(run.card.stale); - REQUIRE_FALSE(run.card.journal.empty()); - CHECK(run.card.journal.back().find("DEVICE LOST") != std::string::npos); - check_journal_rules(run); -} - -TEST_CASE("a first reset in the core confirm probe backs off four steps") { - Run run = recovering(); - run.card.lost_on_first_core_confirm = true; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 1); - CHECK(r.core_max_stable == 150); - CHECK(run.logged("confirm core +150 / mem +550: DEVICE LOST")); - CHECK(run.logged("confirm core +90 / mem +550: STABLE")); - // The climb, the lost confirm probe, and the next try four steps down. - CHECK(core_begins(run.card) == climb(15, 165, 15, {150, 90})); - CHECK(r.core_confirmed == 90); - CHECK(r.core_mhz == 60); - // The gate lies between the two confirm entries. - CHECK(first_events_after_lost(run.card, 5) == Events{"recover", "stock", "rest", "health", "begin"}); - CHECK(run.card.core == 60); - CHECK(run.card.mem == 550); // the full state was rewritten after the reset - CHECK(run.card.recover_calls == 1); - CHECK_FALSE(run.card.stale); - check_reconnected_up_front(run); - check_journal_rules(run); -} - -TEST_CASE("a second reset in the soak ends the run and starts nothing") { - Run run = recovering(); - run.card.lost_core_from = 135; - run.card.lost_on_first_soak = true; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the driver reset twice"); - CHECK(r.driver_resets == 2); - CHECK(run.logged("soak: 300 s at power 115 %, core +30, mem +550")); - CHECK(run.logged("soak: DEVICE LOST")); - CHECK(soak_entries(run.card) == 1); - REQUIRE_FALSE(run.card.journal.empty()); - CHECK(run.card.journal.back().find("DEVICE LOST") != std::string::npos); - check_nothing_started(events_after_last_begin(run.card)); - CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); - CHECK(run.card.rests == 1); - CHECK(count_events(run.card, "health") == 1); - // 32 probes up to the lost core +135, the health probe (33), the confirm - // probe at +60 (34), the lost soak (35), and none after it. - CHECK(run.card.probes == 35); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - CHECK(run.card.power == 100); - CHECK(r.stock_restored); - check_journal_rules(run); -} - -TEST_CASE("a second reset as a failed soak write ends the run and starts nothing") { - Run run = recovering(); - run.card.lost_core_from = 135; // the first event, in the core climb - run.card.fail_set_on_first_soak = true; // the second: the soak's core write, +30 - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the driver reset twice"); - CHECK(r.driver_resets == 2); - CHECK(run.logged("setting core +30 failed; treated as a driver reset")); - CHECK(soak_entries(run.card) == 1); - REQUIRE_FALSE(run.card.journal.empty()); - CHECK(run.card.journal.back().find("SET FAILED") != std::string::npos); - CHECK(run.card.count("SET FAILED") == 1); - CHECK(run.count_logged("soak: 300 s at") == 0); // the soak never ran - check_nothing_started(events_after_last_begin(run.card)); - CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); - CHECK(run.card.rests == 1); // the one of the first event - CHECK(count_events(run.card, "health") == 1); - // 32 probes up to the lost core +135, the health probe (33), the confirm - // probe at +60 (34), and none after it: the soak's write failed. - CHECK(run.card.probes == 34); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - CHECK(run.card.power == 100); - CHECK(r.stock_restored); - check_journal_rules(run); -} - -TEST_CASE("a first reset in the soak backs both clocks off four steps") { - Run run = recovering(); - run.card.lost_on_first_soak = true; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 1); - CHECK(run.logged("soak: DEVICE LOST")); - CHECK(run.logged("soak: 300 s at power 115 %, core +45, mem +350")); - CHECK(soak_entries(run.card) == 2); - CHECK(run.card.probes == 38); // the lost soak (probe 36), the health probe at stock, the second soak - CHECK(count_events(run.card, "health") == 1); - CHECK(first_events_after_lost(run.card, 5) == Events{"recover", "stock", "rest", "health", "begin"}); - CHECK(r.soak.seconds == 300); - CHECK(r.core_mhz == 45); - CHECK(r.mem_mhz == 350); - CHECK(run.card.core == 45); - CHECK(run.card.mem == 350); - CHECK(run.card.power == 115); - CHECK(run.card.recover_calls == 1); - CHECK_FALSE(run.card.stale); - check_reconnected_up_front(run); - check_journal_rules(run); -} - -TEST_CASE("a reset during the health probe after a soak reset ends the run") { - Run run = recovering(); - run.card.lost_on_first_soak = true; - run.card.health_verdict = Verdict::DeviceLost; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the card was not usable when checked after a driver reset (DEVICE LOST)"); - CHECK(r.driver_resets == 2); - CHECK(run.card.probes == 37); // the lost soak is probe 36; then only the health probe - CHECK(soak_entries(run.card) == 1); - // The gate, called from the soak, stops the run: no rest, no second health - // probe and no entry after it; one reconnect and one reset on the way out. - CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "health", "recover", "stock"}); - CHECK(r.stock_restored); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - CHECK(run.card.power == 100); - check_journal_rules(run); -} - -TEST_CASE("a reset during the baseline ends the run") { - Run run = recovering(); - run.card.lost_at_probe = 1; // the baseline - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the driver reset at stock clocks (DEVICE LOST)"); - CHECK(r.driver_resets == 1); - CHECK(r.baseline.seconds == 30); - CHECK(run.card.probes == 1); - CHECK(count_events(run.card, "health") == 0); - CHECK(run.card.rests == 0); - CHECK(r.stock_restored); - CHECK_FALSE(run.card.stale); - CHECK(run.card.recover_calls == 1); - CHECK(run.card.journal.empty()); - check_journal_rules(run); -} - -TEST_CASE("without a reconnect a reset during the baseline keeps its old reason") { - Run run; - run.card.lost_at_probe = 1; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "stock is not stable (DEVICE LOST)"); - CHECK(r.driver_resets == 1); // still counted - CHECK(run.card.probes == 1); -} - -TEST_CASE("a reset during the power step ends the run") { - Run run = recovering(); - run.card.lost_at_probe = 2; // the first 20 s probe, at 85 % - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(run.logged("power 85 %: DEVICE LOST")); - CHECK(r.reason == "the driver reset at stock clocks (DEVICE LOST)"); - CHECK(r.driver_resets == 1); - CHECK(run.card.probes == 2); - CHECK(run.card.power == 100); - CHECK(run.card.core == 0); - CHECK(count_events(run.card, "health") == 0); - CHECK(run.card.rests == 0); - CHECK(r.stock_restored); - CHECK_FALSE(run.card.stale); - CHECK(run.card.recover_calls == 1); - CHECK(run.card.journal.empty()); - check_journal_rules(run); -} - -TEST_CASE("one blind probe ends the climb at the last value that passed") { - Run run = recovering(); - run.card.blind_at_core = 135; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 1); - CHECK(r.core_max_stable == 120); - CHECK(run.logged("core +135 / mem +550: NO TELEMETRY")); - // No 3 s core probe after it: nine climb entries and one confirm entry, - // four steps below +120. That 30 s probe is not blind and holds. - CHECK(run.card.max_core_seen == 135); - const auto begins = core_begins(run.card); - REQUIRE(begins.size() == 10); - CHECK(std::vector(begins.begin(), begins.begin() + 9) == climb(15, 135, 15)); - CHECK(begins[9] == 60); - CHECK(r.core_confirmed == 60); - CHECK(run.card.recover_calls == 1); - check_journal_rules(run); -} - -TEST_CASE("a blind probe ends the clock search it is in: the memory search, then the core search") { - Run run = recovering(); - run.card.blind_probes = 2; // the first two 3 s probes are blind - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 2); - // The first blind probe is the first 3 s one, memory +50. It is not a - // measured failure and nothing is bisected below it: the memory stays at - // stock. The core has a search of its own, and its first probe, +15, is - // the second blind one: the core stays at stock too, and no third 3 s - // probe runs. - CHECK(run.logged("core +0 / mem +50: NO TELEMETRY")); - CHECK(run.logged("core +15 / mem +0: NO TELEMETRY")); - CHECK(run.card.blind_probes == 0); - CHECK(mem_begins(run.card) == std::vector{50}); - CHECK(core_begins(run.card) == std::vector{15}); - CHECK(r.mem_max_stable == 0); - CHECK(r.mem_mhz == 0); - CHECK(r.core_mhz == 0); - CHECK(run.card.max_mem_seen == 50); - CHECK(run.card.max_core_seen == 15); - CHECK(run.card.recover_calls == 2); - CHECK_FALSE(run.logged("lost telemetry")); - check_journal_rules(run); -} - -TEST_CASE("a failed candidate write is a reset event and goes through the gate") { - Run run = recovering(); - run.card.fail_core_set_once_at = 135; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 1); - CHECK(run.logged("setting core +135 failed; treated as a driver reset")); - CHECK_FALSE(run.logged("trying once more")); - // The +135 entry is closed as SET FAILED and the value gets no second entry. - CHECK(run.card.count("SET FAILED") == 1); - CHECK(core_begins(run.card) == climb(15, 135, 15, {60})); - CHECK(run.card.max_core_seen == 120); - CHECK(first_events_after_lost(run.card, 5) == Events{"recover", "stock", "rest", "health", "begin"}); - CHECK(r.core_max_stable == 120); - CHECK(r.core_confirmed == 60); - CHECK(run.card.recover_calls == 1); - CHECK(run.card.recovers_with_entry_open == 0); - check_journal_rules(run); -} - -TEST_CASE("a write that keeps failing is never written again and the run completes below it") { - Run run = recovering(); - run.card.fail_core_set_at = 150; // every write of +150 fails - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 1); - CHECK(run.logged("setting core +150 failed; treated as a driver reset")); - CHECK(run.card.count("SET FAILED") == 1); // one entry for the value, and no retry - CHECK(core_begins(run.card) == climb(15, 150, 15, {75})); - CHECK(run.card.max_core_seen == 135); - CHECK(r.core_max_stable == 135); - CHECK(r.core_confirmed == 75); - CHECK(r.core_mhz == 45); - CHECK(run.card.core == 45); - CHECK(run.card.mem == 550); - CHECK(run.card.power == 115); - CHECK(run.card.recover_calls == 1); - check_journal_rules(run); -} - -TEST_CASE("a failed write on the first core candidate leaves the core at stock") { - Run run = recovering(); - run.card.flaky_core_sets = 1; // the first non-zero core write, +15 - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 1); - CHECK(run.card.count("SET FAILED") == 1); - CHECK(core_begins(run.card) == std::vector{15}); // no retry, and nothing to confirm above stock - CHECK(r.core_max_stable == 0); - CHECK(r.core_mhz == 0); - CHECK(run.card.max_core_seen == 0); - CHECK(r.mem_mhz == 550); - CHECK(run.card.mem == 550); - CHECK(run.card.recover_calls == 1); - check_journal_rules(run); -} - -TEST_CASE("a failed write after an earlier reset ends the run") { - Run run = recovering(); - run.card.lost_core_from = 135; - run.card.fail_next_core_set_after_recover = true; // the write of the confirm candidate, +60 - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the driver reset twice"); - CHECK(r.driver_resets == 2); - CHECK(run.logged("setting core +60 failed; treated as a driver reset")); - CHECK(core_begins(run.card) == climb(15, 135, 15, {60})); - REQUIRE_FALSE(run.card.journal.empty()); - CHECK(run.card.journal.back().find("SET FAILED") != std::string::npos); - CHECK(run.card.count("SET FAILED") == 1); - check_nothing_started(events_after_last_begin(run.card)); - CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); - CHECK(r.stock_restored); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - CHECK(run.card.power == 100); - check_journal_rules(run); -} - -TEST_CASE("a failed write in the power step ends the run") { - Run run = recovering(); - run.card.flaky_power_sets = 1; // the write of the first power step, 85 % - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the driver reset at stock clocks (setting power 85 % failed)"); - CHECK(run.logged("setting power 85 % failed; treated as a driver reset")); - CHECK(r.driver_resets == 1); - CHECK(run.card.probes == 1); // the baseline only - CHECK(run.card.power == 100); - CHECK(run.card.recover_calls == 1); // on the way out; no retry of the write - CHECK(count_events(run.card, "health") == 0); - CHECK(run.card.rests == 0); - CHECK(r.stock_restored); - CHECK(run.card.journal.empty()); -} - -TEST_CASE("without a reconnect a failed write in the power step stops the run as before") { - Run run; - run.card.flaky_power_sets = 1; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "setting power 85 % failed"); - CHECK(r.driver_resets == 0); - CHECK(run.card.power == 100); - CHECK(r.stock_restored); -} - -TEST_CASE("a failed write of the soak state is a soak reset") { - Run run = recovering(); - run.card.fail_set_on_first_soak = true; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 1); - CHECK(run.logged("setting core +105 failed; treated as a driver reset")); - CHECK(soak_entries(run.card) == 2); - CHECK(run.card.count("SET FAILED") == 1); - // The first soak entry is the one closed as SET FAILED: the only soak that - // ran is the second, four steps down, after the gate. - CHECK(run.count_logged("soak: 300 s at") == 1); - CHECK(run.logged("soak: 300 s at power 115 %, core +45, mem +350")); - CHECK(first_events_after_lost(run.card, 5) == Events{"recover", "stock", "rest", "health", "begin"}); - CHECK(r.core_mhz == 45); - CHECK(r.mem_mhz == 350); - CHECK(run.card.core == 45); - CHECK(run.card.mem == 350); - CHECK(run.card.recover_calls == 1); - check_journal_rules(run); -} - -TEST_CASE("a failed bandwidth measurement ends the memory scan; the core is still searched") { - Run run = recovering(); - run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; - run.card.bw_fails_once_at_mem = 300; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 1); - // The scan samples +0 .. +300. +250 was the highest step that passed, so - // the second pass would begin at +50; after a reset nothing is read, and - // confirming starts four steps below that, which is stock. The +0 sample - // names no clock in the journal. - CHECK(mem_begins(run.card) == climb(50, 300, 50)); - CHECK(clockless_begins(run.card) == 1); - // +0, +50 .. +300, core +15 .. +165, the core confirm probe, the soak - CHECK(run.card.count("\"begin\"") == 20); - CHECK(run.logged("core +0 / mem +0: STABLE")); - CHECK(run.logged("mem +0: bandwidth 500.0 GB/s")); - CHECK(run.card.max_mem_seen == 300); - CHECK(run.card.bw_measurements == 7); - CHECK(run.card.bw_again_measurements == 0); - CHECK(r.mem_max_stable == 50); - CHECK(r.mem_confirmed == 0); - CHECK(r.mem_mhz == 0); - CHECK(run.card.max_core_seen == 165); - CHECK(r.core_mhz == 105); - CHECK_FALSE(run.logged("core: not searched")); - CHECK(run.card.recover_calls == 1); - CHECK(run.card.count("SET FAILED") == 0); // reconnected before the next entry, not after a failed write in it - CHECK(run.logged("the driver was reset; reconnecting")); - CHECK_FALSE(run.card.stale); - check_journal_rules(run); -} - -TEST_CASE("bandwidth readings that do not settle end the memory scan and are no reset") { - Run run = recovering(); - run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; - run.card.bw_unsettled_at_mem = 300; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 0); - CHECK(run.card.recover_calls == 0); - CHECK_FALSE(run.logged("treated as a driver reset")); - CHECK(run.logged("mem +300: the bandwidth readings did not settle; the memory scan ends here")); - // The scan ends at +300; +250 was the highest step that passed, so the - // second pass begins at +50, and the confirm probe runs at what it kept, - // not four steps below that. - CHECK(run.card.max_mem_seen == 300); - CHECK(run.card.max_again_mem == 50); - CHECK(r.mem_max_stable == 50); - CHECK(r.mem_confirmed == 50); - // The core is searched as in a run without any event. - CHECK(run.card.max_core_seen > 0); - CHECK(r.core_mhz > 0); - CHECK_FALSE(run.logged("core: not searched")); - check_journal_rules(run); -} - -TEST_CASE("a reset in the memory scan ends that search only; the core is still searched") { - Run run = recovering(); - run.card.lost_mem_from = 300; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 1); - CHECK(run.logged("core +0 / mem +300: DEVICE LOST")); - CHECK(run.logged("mem: the driver reset at +300; using +250, the last value that passed")); - // The climb ends at +300; the confirm probe starts four steps below +250. - CHECK(mem_begins(run.card) == climb(50, 300, 50, {50})); - CHECK(r.mem_max_stable == 250); - CHECK(r.mem_confirmed == 50); - CHECK(r.mem_mhz == 0); - // Each clock has its own search: the core climbs to its edge as in a run - // without a reset (+165 is the first value that fails), and is confirmed - // at the edge, not four steps below it. - CHECK_FALSE(run.logged("core: not searched")); - // The last core entry is the soak's: with memory at +0 it names only the core. - CHECK(core_begins(run.card) == climb(15, 165, 15, {150, 105})); - CHECK(run.card.max_core_seen == 165); - CHECK(r.core_max_stable == 150); - CHECK(r.core_confirmed == 150); - CHECK(r.core_mhz == 105); - CHECK(run.card.mem == 0); - CHECK(run.card.core == 105); - CHECK(run.card.power == 115); // the full state was rewritten after the reset - CHECK(run.card.recover_calls == 1); - CHECK_FALSE(run.card.stale); - check_reconnected_up_front(run); - check_journal_rules(run); -} - -TEST_CASE("no bandwidth is measured after a reset") { - Run run = recovering(); - run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; - run.card.lost_mem_from = 300; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(run.logged("core +0 / mem +300: DEVICE LOST")); - CHECK(run.card.bw_measurements == 6); // +0 .. +250, and none after the lost candidate - CHECK(run.card.max_mem_seen == 300); -} - -TEST_CASE("a measurement that is unavailable from the start leaves the memory at stock") { - // A 3080 Ti, 0.4.3: the measurement failed at +0, the search climbed on - // stability alone to the first wrong result at +2300, and the machine froze - // in the confirm probes just below it. The bandwidth peak is what keeps a - // memory search away from there; without it nothing is searched. - Run run = recovering(); - run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; - run.card.bw_fails_at_zero = true; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 0); - CHECK(run.card.bw_measurements == 1); - // The +0 sample ran and is the first entry of the run, without a clock. - // No memory offset was ever written. - CHECK(mem_begins(run.card).empty()); - CHECK(run.card.max_mem_seen == 0); - CHECK(clockless_begins(run.card) == 1); - REQUIRE_FALSE(run.card.journal.empty()); - CHECK(run.card.journal.front().find("\"mem\":") == std::string::npos); - CHECK(run.card.journal.front().find("\"core\":") == std::string::npos); - CHECK(run.logged("core +0 / mem +0: STABLE")); - CHECK(r.mem_max_stable == 0); - CHECK(r.mem_confirmed == 0); - CHECK(r.mem_mhz == 0); - CHECK(r.core_max_stable == 150); // the core is still searched - CHECK(r.core_mhz > 0); - CHECK(run.card.rests == 0); - CHECK(count_events(run.card, "health") == 0); - CHECK(run.card.recover_calls == 0); - CHECK(run.logged("the bandwidth measurement does not work here; the memory is not overclocked")); - CHECK(run.logged("mem: left at stock")); - CHECK_FALSE(run.logged("searching memory by stability only")); - CHECK_FALSE(run.logged("mem: highest stable")); - CHECK_FALSE(run.logged("bandwidth peak")); - CHECK_FALSE(run.logged("the driver reset during this run")); - check_journal_rules(run); -} - -TEST_CASE("a measurement that fails at +0 because the device was lost is caught by the next write") { - Run run = recovering(); - run.card.bw_curve = [](int m) { return 500 + m * 0.1; }; - run.card.bw_loses_device = true; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 1); - CHECK(run.logged("the bandwidth measurement does not work here; the memory is not overclocked")); - CHECK(run.card.bw_measurements == 1); - // Journal: +0 opened (without a clock) and closed. No memory offset is - // tried; the write of stock that follows the failed measurement is the - // next write, and it is what finds the reset: in the memory search, so the - // core keeps its own. After it: the core search (+15 .. +165, the confirm - // probe) and the soak. - CHECK(mem_begins(run.card).empty()); - CHECK(clockless_begins(run.card) == 1); - CHECK(run.card.count("\"begin\"") == 14); - CHECK(run.logged("core +0 / mem +0: STABLE")); - REQUIRE_FALSE(run.card.journal.empty()); - CHECK(run.card.journal[0].find("\"begin\"") != std::string::npos); - CHECK(run.card.journal[0].find("\"mem\":") == std::string::npos); - CHECK(run.card.count("SET FAILED") == 0); - CHECK(run.logged("failed; treated as a driver reset")); - CHECK(first_events_after_lost(run.card, 5) == Events{"recover", "stock", "rest", "health", "begin"}); - CHECK(r.mem_mhz == 0); - CHECK(run.card.max_core_seen == 165); - CHECK_FALSE(run.logged("core: not searched")); - CHECK(r.core_mhz == 105); - CHECK(run.card.core == r.core_mhz); - CHECK(run.card.recover_calls == 1); - check_journal_rules(run); -} - -TEST_CASE("a reset verdict on the +0 memory sample ends the run") { - Run run = recovering(); - run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; - run.card.lost_mem_from = 0; // every 3 s probe, the +0 sample first - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the driver reset at stock clocks (DEVICE LOST)"); - CHECK(r.driver_resets == 1); - // The +0 sample is the only entry of the run, and it names no clock. - CHECK(run.card.count("\"begin\"") == 1); - CHECK(clockless_begins(run.card) == 1); - CHECK(mem_begins(run.card).empty()); - CHECK(run.logged("core +0 / mem +0: DEVICE LOST")); - CHECK(run.card.max_mem_seen == 0); - CHECK(run.card.bw_measurements == 0); - CHECK(run.card.rests == 0); - CHECK(count_events(run.card, "health") == 0); - CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); - CHECK(r.stock_restored); - check_journal_rules(run); -} - -TEST_CASE("a reset in the memory search and one in the core climb: both clocks back off, the run goes on") { - Run run = recovering(); - run.card.lost_mem_from = 300; - run.card.lost_core_from = 135; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 2); - CHECK(run.card.recover_calls == 2); - CHECK(run.logged("mem: the driver reset at +300; using +250, the last value that passed")); - CHECK(run.logged("core: the driver reset at +135; using +120, the last value that passed")); - // The core climb ends at +135 and its confirm probe starts four steps - // below +120; nothing above the lost value is tried. The last core entry - // is the soak's, at +30. - CHECK(core_begins(run.card) == climb(15, 135, 15, {60, 30})); - CHECK(run.card.max_core_seen == 135); - CHECK(r.core_max_stable == 120); - CHECK(r.core_confirmed == 60); - CHECK(r.mem_confirmed == 50); - CHECK_FALSE(run.card.stale); - check_journal_rules(run); -} - -TEST_CASE("a third reset ends the run and starts nothing") { - Run run = recovering(); - run.card.lost_mem_from = 300; // the first event, in the memory climb - run.card.lost_core_from = 135; // the second, in the core climb - run.card.lost_on_first_soak = true; // the third - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE_FALSE(r.ok); - CHECK(r.reason == "the driver reset three times"); - CHECK(r.driver_resets == 3); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - check_journal_rules(run); -} - -TEST_CASE("a first reset in the memory confirm probe: the core is still searched") { - Run run = recovering(); - run.card.lost_on_first_mem_confirm = true; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 1); - CHECK(run.logged("confirm core +0 / mem +800: DEVICE LOST")); - // The next confirm try is four steps down. The core then has a search of - // its own, with memory +400 applied, and the soak names both clocks. - CHECK(mem_begins(run.card) == climb(50, 850, 50, {800, 600})); - CHECK(core_begins(run.card) == climb(15, 165, 15, {150})); - CHECK(r.mem_max_stable == 800); - CHECK(r.mem_confirmed == 600); - CHECK(r.mem_mhz == 400); - CHECK(run.card.mem == 400); - CHECK(run.card.max_core_seen == 165); - CHECK(r.core_mhz == 105); - CHECK_FALSE(run.logged("core: not searched")); - CHECK(run.logged("soak: 300 s at power 115 %, core +105, mem +400")); - CHECK(run.card.recover_calls == 1); - check_journal_rules(run); -} - -TEST_CASE("a soak entry carries only the clocks that are above stock") { - // A card whose core holds nothing above stock: the soak runs at core +0 / - // memory +400. - Run run = recovering(); - run.card.lost_on_first_mem_confirm = true; - run.card.core_edge = 0; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.core_mhz == 0); - CHECK(r.mem_mhz == 400); - CHECK(run.logged("soak: 300 s at power 115 %, core +0, mem +400")); - // The soak's entry is the last one of the run: its `begin` and its `complete`. - REQUIRE(run.card.journal.size() >= 2); - const std::string& soak_begin = run.card.journal[run.card.journal.size() - 2]; - REQUIRE(soak_begin.find("\"begin\"") != std::string::npos); - CHECK(soak_begin.find("\"mem\":400") != std::string::npos); - CHECK(soak_begin.find("\"core\":") == std::string::npos); - // A machine that froze during that soak leaves the entry open. That must - // not become a core ceiling of 0, which would keep every later run from - // searching the core at all. - REQUIRE(run.card.journal.back().find("\"complete\"") != std::string::npos); - const std::vector frozen(run.card.journal.begin(), run.card.journal.end() - 1); - Journal reread(frozen, [](const std::string&) { return true; }); - CHECK(reread.freezes().size() == 1); - CHECK(reread.ceilings().core_mhz == INT_MAX); - CHECK(reread.ceilings().mem_mhz == 400); -} - -TEST_CASE("the +0 sample of the bandwidth scan journals no clock") { - Run run; - run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.mem_max_stable == 600); // +800 passed; the second pass began at +600 - // The first entry of the run is the +0 sample: it ran and was measured, - // and its `begin` line names neither clock. - CHECK(run.logged("core +0 / mem +0: STABLE")); - CHECK(run.logged("mem +0: bandwidth 500.0 GB/s")); - REQUIRE(run.card.journal.size() >= 3); - const std::string& sample_begin = run.card.journal[0]; - REQUIRE(sample_begin.find("\"begin\"") != std::string::npos); - CHECK(sample_begin.find("\"mem\":") == std::string::npos); - CHECK(sample_begin.find("\"core\":") == std::string::npos); - REQUIRE(run.card.journal[1].find("\"complete\"") != std::string::npos); - // The entry after it is the first one above stock, and it names its clock. - CHECK(run.card.journal[2].find("\"mem\":50") != std::string::npos); - CHECK(clockless_begins(run.card) == 1); // the soak runs above stock - REQUIRE_FALSE(mem_begins(run.card).empty()); - CHECK(mem_begins(run.card).front() == 50); - - // A machine that froze during that sample leaves its entry open. That is - // a freeze, and it must not become a ceiling of 0 for either clock. - const std::vector frozen{sample_begin}; - Journal reread(frozen, [](const std::string&) { return true; }); - CHECK(reread.freezes().size() == 1); - CHECK(reread.ceilings().mem_mhz == INT_MAX); - CHECK(reread.ceilings().core_mhz == INT_MAX); - - // The next run on that journal searches memory as far as the first did. - Run next; - next.card.bw_curve = run.card.bw_curve; - next.card.journal = frozen; - const auto b = next.go(Preset::BestOfMyGpu); - REQUIRE(b.ok); - CHECK(b.mem_max_stable == 600); - CHECK(b.core_max_stable == 150); -} - -TEST_CASE("a soak reset at stock clocks ends the run") { - Run run = recovering(); - run.card.lost_on_first_soak = true; - const auto r = run.go(Preset::CoolAndEfficient); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the driver reset at stock clocks (DEVICE LOST)"); - CHECK(r.driver_resets == 1); - CHECK(run.card.count("\"begin\"") == 1); // exactly one soak entry - CHECK(run.count_logged("soak: 300 s at") == 1); - CHECK(run.card.rests == 0); - CHECK(count_events(run.card, "health") == 0); - CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); - CHECK(r.stock_restored); - CHECK(run.card.power == 100); - check_journal_rules(run); -} - -TEST_CASE("a failed soak write at stock clocks ends the run") { - // The cool preset tunes neither clock: its soak runs at core +0 / memory - // +0, and its entry is the only one of the run. - Run clean = recovering(); - REQUIRE(clean.go(Preset::CoolAndEfficient).ok); - - Run run = recovering(); - run.card.fail_power_set_in_entry = true; // the soak's power write - const auto r = run.go(Preset::CoolAndEfficient); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the driver reset at stock clocks (setting power 85 % failed)"); - CHECK(run.logged("setting power 85 % failed; treated as a driver reset")); - CHECK(r.driver_resets == 1); - CHECK_FALSE(run.card.fail_power_set_in_entry); // the knob fired - CHECK(run.card.count("\"begin\"") == 1); // exactly one soak entry - REQUIRE(run.card.journal.size() == 2); - CHECK(run.card.journal.back().find("SET FAILED") != std::string::npos); - CHECK(run.count_logged("soak: 300 s at") == 0); // the soak never ran - CHECK(run.card.probes == clean.card.probes - 1); // every probe of a clean run but the soak - CHECK(run.card.rests == 0); - CHECK(count_events(run.card, "health") == 0); - CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); - CHECK(run.card.recover_calls == 1); - CHECK(r.stock_restored); - CHECK(run.card.power == 100); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - check_journal_rules(run); -} - -TEST_CASE("a failed write of the +0 memory sample ends the run") { - Run run = recovering(); - run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; - run.card.fail_power_set_in_entry = true; // the first entry of the run is the +0 sample - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the driver reset at stock clocks (setting power 115 % failed)"); - CHECK(run.logged("setting power 115 % failed; treated as a driver reset")); - CHECK(r.driver_resets == 1); - CHECK_FALSE(run.card.fail_power_set_in_entry); // the knob fired - // The +0 sample is the only entry of the run, and it names no clock. - CHECK(run.card.count("\"begin\"") == 1); - CHECK(clockless_begins(run.card) == 1); - CHECK(mem_begins(run.card).empty()); - CHECK(run.card.max_mem_seen == 0); - REQUIRE(run.card.journal.size() == 2); - CHECK(run.card.journal.back().find("SET FAILED") != std::string::npos); - CHECK(run.card.probes == 5); // the baseline and four power probes; the sample never ran - CHECK(run.card.bw_measurements == 0); - CHECK(run.card.rests == 0); - CHECK(count_events(run.card, "health") == 0); - CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); - CHECK(run.card.recover_calls == 1); - CHECK(r.stock_restored); - CHECK(run.card.power == 100); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - check_journal_rules(run); -} - -TEST_CASE("a completed run says that the driver reset") { - Run run = recovering(); - run.card.lost_core_from = 135; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(run.logged("the driver reset during this run; the search stopped exploring there")); - - Run clean = recovering(); - const auto b = clean.go(Preset::BestOfMyGpu); - REQUIRE(b.ok); - CHECK_FALSE(clean.logged("the driver reset during this run")); -} - -TEST_CASE("without a reconnect a failed write stops the run as before") { - Run run; - run.card.fail_core_set_at = 150; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "setting core +150 failed"); - CHECK(r.driver_resets == 0); - CHECK(r.stock_restored); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); // core +150 was written with memory +550 applied - CHECK(run.card.power == 100); - CHECK(run.card.count("SET FAILED") == 1); - CHECK_FALSE(run.logged("treated as a driver reset")); -} - -TEST_CASE("an ordinary failure costs no reset") { - Run run = recovering(); - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 0); - CHECK(r.core_max_stable == 150); - CHECK(r.core_confirmed == 150); - CHECK(run.card.recover_calls == 0); - CHECK(run.card.rests == 0); -} - -TEST_CASE("a stop request during the reconnect after a failed write is reported as aborted") { - Run run = recovering(); - run.card.abort_in_recover = true; - run.card.fail_core_set_at = 150; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "aborted"); - CHECK(r.stock_restored); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - CHECK(run.card.power == 100); - CHECK(run.card.count("SET FAILED") == 1); - // The reconnect of the gate, stock, and then nothing but the reset on the way out. - CHECK(events_after_lost(run.card) == Events{"recover", "stock", "stock"}); - check_journal_rules(run); -} - -TEST_CASE("a stop request that arrived during the reconnect ends the run before the rest") { - Run run = recovering(); - run.card.lost_core_from = 135; - run.card.abort_in_recover = true; // the reconnect itself succeeds - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "aborted"); - CHECK(run.card.rests == 0); - CHECK_FALSE(run.logged("resting")); - CHECK(count_events(run.card, "health") == 0); - CHECK(events_after_lost(run.card) == Events{"recover", "stock", "stock"}); - CHECK(r.stock_restored); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - check_journal_rules(run); -} - -TEST_CASE("a health probe that is stopped ends the run as aborted") { - Run run = recovering(); - run.card.lost_core_from = 135; - run.card.abort_in_probe_s = 5; - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "aborted"); - CHECK(r.driver_resets == 1); - CHECK(count_events(run.card, "health") == 1); - CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "health", "stock"}); - CHECK(r.stock_restored); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - CHECK(run.card.power == 100); - check_journal_rules(run); -} - -TEST_CASE("a reconnect that fails on the way out after a lost health probe is reported, not claimed as stock") { - Run run = recovering(); - run.card.lost_core_from = 135; - run.card.health_verdict = Verdict::DeviceLost; - run.card.recover_fails_from_call = 2; // the reconnect of the gate works, the one on the way out does not - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "the card was not usable when checked after a driver reset (DEVICE LOST)"); - CHECK(r.driver_resets == 2); - CHECK_FALSE(r.stock_restored); - CHECK(run.card.recover_calls == 2); - CHECK(run.logged("reset FAILED")); - CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "health", "recover"}); - check_journal_rules(run); -} - -TEST_CASE("confirm_edge steps down by what step_down says") { - std::vector tried; - const auto fails_at_150_and_above = [&](int v) { tried.push_back(v); return v < 150; }; - CHECK(confirm_edge(150, 0, 15, 3, fails_at_150_and_above, [] { return 4; }) == 90); - CHECK(tried == std::vector{150, 90}); - // Never below lo, and never lo itself: +30 fails, four steps down is past stock. +TEST_CASE("confirm_edge steps down by what step_down says") { + std::vector tried; + const auto fails_at_150_and_above = [&](int v) { tried.push_back(v); return v < 150; }; + CHECK(confirm_edge(150, 0, 15, 3, fails_at_150_and_above, [] { return 4; }) == 90); + CHECK(tried == std::vector{150, 90}); + // Never below lo, and never lo itself: +30 fails, four steps down is past stock. int calls = 0; CHECK(confirm_edge(30, 0, 15, 3, [&](int) { ++calls; return false; }, [] { return 4; }) == 0); CHECK(calls == 1); @@ -2820,601 +460,3 @@ TEST_CASE("confirm_edge steps down by what step_down says") { CHECK(confirm_edge(150, 0, 15, 3, [](int v) { return v <= 135; }, {}) == 135); CHECK(confirm_edge(150, 0, 15, 3, [](int v) { return v <= 135; }) == 135); } - -// The measurement at stock that goes before a run (the fans are the NVIDIA driver's). -static StabilityResult stock_run(Verdict verdict, double seconds, int peak_temp_c) { - StabilityResult s; - s.verdict = verdict; - s.seconds = seconds; - s.peak_temp_c = peak_temp_c; - return s; -} - -TEST_CASE("a card that gets hot at stock with the driver's fans is not refused: the run goes on") { - CHECK(stock_refusal(stock_run(Verdict::Stable, 300, 67)).empty()); - // A 3080 Ti: 85 C under full load with the driver holding its fans at 65 %. Nothing is wrong with it. - CHECK(stock_refusal(stock_run(Verdict::TooHot, 140, kStockTempC + 1)).empty()); - CHECK(stock_refusal(stock_run(Verdict::TooHot, kStockMinS, kStockTempC + 1)).empty()); -} - -TEST_CASE("a card that was hot before the measurement at stock got going is asked to cool down first") { - const std::string why = stock_refusal(stock_run(Verdict::TooHot, 4, 88)); - CHECK(why.find("88 C") != std::string::npos); - CHECK(why.find("cool down") != std::string::npos); - CHECK(why.find("not stable") == std::string::npos); -} - -TEST_CASE("a card that fails at stock for another reason gets no run") { - CHECK(stock_refusal(stock_run(Verdict::WrongResult, 12, 60)) == "the card is not stable at stock (WRONG RESULT)"); - CHECK(stock_refusal(stock_run(Verdict::DeviceLost, 12, 60)) == "the card is not stable at stock (DEVICE LOST)"); - CHECK(stock_refusal(stock_run(Verdict::NoTelemetry, 12, 60)) == "the card is not stable at stock (NO TELEMETRY)"); - CHECK(stock_refusal(stock_run(Verdict::Aborted, 12, 60)) == "aborted"); -} - -TEST_CASE("a run does not start while MSI Afterburner or RivaTuner runs") { - const std::string ab = other_tool_refusal({"explorer.exe", "MSIAfterburner.exe", "Discord.exe"}); - CHECK(ab.find("MSI Afterburner is running") == 0); - CHECK(ab.find("Close it") != std::string::npos); - const std::string rtss = other_tool_refusal({"RTSS.exe"}); - CHECK(rtss.find("RivaTuner Statistics Server (RTSS) is running") == 0); - // Windows does not care about the case of a file name. - CHECK_FALSE(other_tool_refusal({"msiafterburner.EXE"}).empty()); - CHECK_FALSE(other_tool_refusal({"rtss.exe"}).empty()); - // Afterburner starts RivaTuner with it: the one the user knows is named. - CHECK(other_tool_refusal({"RTSS.exe", "MSIAfterburner.exe"}).find("MSI Afterburner") == 0); -} - -TEST_CASE("a run starts when neither runs, whatever else does") { - CHECK(other_tool_refusal({}).empty()); - CHECK(other_tool_refusal({"explorer.exe", "Discord.exe", "GpuAutoOptimizer.exe", "gao.exe"}).empty()); - // Only the programs themselves: not a file that merely carries the name. - CHECK(other_tool_refusal({"RTSSHooksLoader64.exe", "MSIAfterburner.exe.bak", "NotRTSS.exe", "Afterburner"}).empty()); -} - -// The core offsets of a run's 30 s confirm probes, in the order they ran. -static std::vector core_confirms(const FakeCard& card) { - std::vector out; - for (const std::string& p : card.probe_order) { - if (p.rfind("30:", 0) != 0) continue; - const auto core_at = p.find(':', 3) + 1; - const int core = std::stoi(p.substr(core_at, p.find(':', core_at) - core_at)); - if (core != 0) out.push_back(core); // core 0 is the memory's confirm probe - } - return out; -} - -TEST_CASE("a confirm probe that is too hot before the clock was tested confirms nothing") { - // A 3080 Ti, 0.4.5: the driver reset at core +300, the health probe at - // stock power left the card at 76 C, and `confirm core +225: TOO HOT` - // ended within a second. That counted as holding, and +225 was confirmed - // by a probe that had tested nothing. - Run run = lost_at_135(); - run.card.hot_after_health_s = 0.6; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 1); - // The reset came at +135, +120 was the last value that passed, and the - // confirm starts four steps below it. That probe ends too hot at once and - // does not count; the card cools, and one step lower runs its 30 s. - CHECK(core_confirms(run.card) == std::vector{60, 45}); - CHECK(r.core_confirmed == 45); - CHECK(r.core_mhz <= 45); - CHECK(run.logged("confirm core +60 / mem +550: TOO HOT")); - CHECK(run.logged("too hot after 0.6 s, before the clock was tested: not confirmed")); - CHECK(run.card.rests == 2); // after the reset, and to cool down before the next try - check_journal_rules(run); -} - -TEST_CASE("a confirm probe that got too hot after the clock was tested still keeps the clock edge") { - Run run = lost_at_135(); - run.card.hot_after_health_s = kConfirmMinS; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(core_confirms(run.card) == std::vector{60}); - CHECK(r.core_confirmed == 60); - CHECK(run.card.rests == 1); - CHECK_FALSE(run.logged("not confirmed")); -} - -TEST_CASE("a stop request while the card cools after such a confirm probe ends the run at stock") { - Run run = lost_at_135(); - run.card.hot_after_health_s = 0.6; - run.card.abort_on_rest = 2; // the second rest is the cool-down - const auto r = run.go(Preset::BestOfMyGpu); - CHECK_FALSE(r.ok); - CHECK(r.reason == "aborted"); - CHECK(r.stock_restored); - CHECK(core_confirms(run.card) == std::vector{60}); -} - -namespace { - -// A card that is warm before anything is changed: 15 C above the usual one, so -// that the profile's 75 C only holds at a power limit of 65 %. The fake card -// scores by its power limit (1000 it/s from 90 % up), so that costs speed, as -// it did on an RTX 3080 Ti: 4749 it/s after the run, 4937 at stock. -Run warm_card(double stock_score) { - Run run; - run.card.extra_heat = 15; - StabilityResult stock; - stock.verdict = Verdict::Stable; - stock.seconds = 300; - stock.score = stock_score; - stock.peak_temp_c = 85; - run.stock = stock; - return run; -} - -} - -TEST_CASE("a profile that is after speed keeps nothing when the result is slower than stock") { - Run run = warm_card(1000); - const auto r = run.go(Preset::BestOfMyGpu); - CAPTURE(r.reason); - CHECK_FALSE(r.ok); - CHECK(r.reason.find("slower than stock") != std::string::npos); - CHECK(r.reason.find("722 it/s against 1000") != std::string::npos); - CHECK(r.reason.find("power limit went down to 65 %") != std::string::npos); - CHECK(r.reason.find("Undervolt") != std::string::npos); - // Not applied: the card is at stock again. - CHECK(r.stock_restored); - CHECK(run.card.core == 0); - CHECK(run.card.mem == 0); - CHECK(run.card.power == 100); - // The search itself ran to its end: the result was tested before it was judged. - CHECK(run.logged("soak: STABLE")); - check_journal_rules(run); -} - -TEST_CASE("max performance keeps nothing either when the card scored more at stock") { - // Its limit is 83 C, so this card keeps a power limit of 90 %: the fake - // card's full 1000 it/s. The card scored more than that at stock. - Run run = warm_card(1100); - const auto r = run.go(Preset::MaxPerformance); - CAPTURE(r.reason); - CHECK_FALSE(r.ok); - CHECK(r.reason.find("slower than stock: 1000 it/s against 1100") != std::string::npos); - CHECK(r.stock_restored); - CHECK(run.card.core == 0); - CHECK(run.card.power == 100); -} - -TEST_CASE("a profile that is after speed keeps a result that is faster than stock, or as fast") { - Run faster = warm_card(700); - const auto r = faster.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.power_pct == 65); - CHECK(faster.card.power == 65); // applied - CHECK(faster.card.core == r.core_mhz); - // Within one percent of stock is the scatter of two five-minute runs, not a slower card. - Run level = warm_card(728); // 722 against 728: 0.8 % less - CHECK(level.go(Preset::BestOfMyGpu).ok); - Run just_slower = warm_card(735); // 1.8 % less - CHECK_FALSE(just_slower.go(Preset::BestOfMyGpu).ok); -} - -TEST_CASE("the profiles that give speed away on purpose keep their result when it is slower than stock") { - // Quiet and Cool & efficient lower the power limit for less noise and - // heat. All in one goes on to an undervolt and a fan tune after this step. - for (const Preset preset : {Preset::Quiet, Preset::CoolAndEfficient, Preset::AllInOne}) { - Run run = warm_card(1000); - if (preset == Preset::CoolAndEfficient) run.card.extra_heat = 5; // its limit is 65 C - const auto r = run.go(preset); - CAPTURE(std::string(preset_name(preset))); - CAPTURE(r.reason); - REQUIRE(r.ok); - CHECK(r.soak.score < 1000); - CHECK(run.card.power == r.power_pct); - } -} - -TEST_CASE("without a measurement at stock there is nothing to be slower than") { - Run run = warm_card(1000); - run.stock.reset(); - CHECK(run.go(Preset::BestOfMyGpu).ok); - Run unscored = warm_card(0); // a measurement that scored nothing says nothing - CHECK(unscored.go(Preset::BestOfMyGpu).ok); -} - -namespace { - -// A card like the reference RTX 4070 (hardware check 94): the bandwidth rises -// by 0.4 % per 50 MHz step all the way to the first wrong result. There is no -// peak to find; only scatter can end the scan before the edge. -Run rising_to_the_edge(int edge = 2500) { - Run run; - run.card.mem_edge = edge; - run.card.ranges = ClockOffsetRanges{{-500, 1000}, {-1000, 3000}}; - run.card.bw_curve = [](int m) { return 434.0 * (1 + 0.004 * m / 50); }; - return run; -} - -// Scatter with a standard deviation of about `sd`, the same for the same -// (seed, offset, reading): three uniform numbers summed, which is bell-shaped -// enough and needs no library whose numbers differ between compilers. -std::function scatter(unsigned seed, double sd) { - return [seed, sd](int mem, int nth) { - unsigned x = seed * 2654435761u + static_cast(mem) * 40503u + static_cast(nth) * 9176u + 12345u; - double sum = 0; - for (int i = 0; i < 3; ++i) { - x ^= x << 13; x ^= x >> 17; x ^= x << 5; - sum += (x % 100000) / 100000.0 - 0.5; // uniform in -0.5 .. 0.5, variance 1/12 - } - return 1.0 + sum * 2.0 * sd; // three of them: variance 1/4, so a deviation of sd - }; -} - -} - -TEST_CASE("one bandwidth reading that is far too high does not end the memory scan") { - // The run that saved +100 MHz on a card that runs every game at +1500: - // one reading came out far above its neighbours, the two after it were - // under it, and that was "the peak". - Run run = rising_to_the_edge(); - run.card.bw_noise = [](int mem, int nth) { return mem == 200 && nth == 0 ? 1.08 : 1.0; }; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(run.card.max_mem_seen == 2550); // the scan went to the first wrong result - CHECK(run.logged("mem +0: bandwidth 434.0 GB/s")); - CHECK(run.card.bw_measurements == 51); // +0 .. +2500, each once: no step was in doubt - // +2500 passed; the second pass reads from +2300 down, and +2200 is the - // lowest offset within 1 % of what it read there. - CHECK(r.mem_max_stable == 2200); - CHECK(r.mem_mhz == 1500); -} - -TEST_CASE("one low bandwidth reading does not end the memory scan; three low ones do") { - { - Run run = rising_to_the_edge(); - run.card.bw_noise = [](int mem, int nth) { return mem == 1000 && nth == 0 ? 0.9 : 1.0; }; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(run.card.bw_readings_at[950] == 1); - CHECK(run.card.bw_readings_at[1000] == kBandwidthReadings); - CHECK(run.card.max_mem_seen == 2550); - CHECK(r.mem_max_stable == 2200); - } - { - // From +1000 on the memory has lost 8 %: that is a peak, whichever copy reads it. - Run run = rising_to_the_edge(); - run.card.bw_curve = [](int m) { return 434.0 * (1 + 0.004 * m / 50) * (m >= 1000 ? 0.92 : 1.0); }; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(run.card.bw_readings_at[1000] == kBandwidthReadings); - CHECK(run.logged("mem: the scan passed +1000; reading again from +800 down")); - CHECK(run.card.max_mem_seen == 1000); - CHECK(run.card.max_again_mem == 800); - CHECK(r.mem_max_stable == 700); // the lowest offset within 1 % of the reading at +800 - } -} - -TEST_CASE("readings that scatter as they do on the reference card leave the memory scan where the card puts it") { - // Measured there: readings scatter by 0.2 % on a card that is left alone - // (hardware check 96). A little more than that here, on two hundred - // different days. Every scan gets to the first wrong result, and the - // offset that is kept moves by five steps: +2050 to +2300. - for (unsigned seed = 1; seed <= 200; ++seed) { - Run run = rising_to_the_edge(); - run.card.bw_noise = scatter(seed, 0.0025); - const auto r = run.go(Preset::BestOfMyGpu); - CAPTURE(seed); - REQUIRE(r.ok); - CHECK(run.card.max_mem_seen == 2550); - CHECK(run.card.max_again_mem == 2300); - CHECK(r.mem_max_stable >= 2050); - CHECK(r.mem_max_stable <= 2300); - } -} - -TEST_CASE("a real peak is still found where it is when the readings scatter") { - // The card the method was made for: bandwidth turns down long before the - // first wrong result. Scatter must not carry the scan past the peak. - for (unsigned seed = 1; seed <= 20; ++seed) { - Run run; - run.card.mem_edge = 1400; - run.card.bw_curve = [](int m) { return m <= 900 ? 500 + m * 0.3 : 770 - (m - 900) * 0.6; }; - run.card.bw_noise = scatter(seed, 0.013); - const auto r = run.go(Preset::BestOfMyGpu); - CAPTURE(seed); - REQUIRE(r.ok); - // +950 is 3.9 % below the peak and +1000 7.8 %: with this scatter the - // scan ends at one of the two, now and then one step later. - CHECK(run.card.max_mem_seen >= 950); - CHECK(run.card.max_mem_seen <= 1050); - CHECK(run.card.max_again_mem <= run.card.max_mem_seen - kPeakMarginSteps * 50); - CHECK(r.mem_max_stable >= 700); - CHECK(r.mem_max_stable <= 900); - } -} - -TEST_CASE("a reading that does not settle is taken again") { - // On the reference card one reading in twenty-four did not settle, with - // nothing wrong. One such reading used to end the scan. - Run run = recovering(); - run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; - run.card.bw_unsettled_once_at_mem = 300; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK_FALSE(run.logged("did not settle")); - CHECK(run.logged("mem +300: bandwidth 590.0 GB/s")); - CHECK(run.card.bw_readings_at[300] == 2); - CHECK(run.card.max_mem_seen > 300); - CHECK(r.driver_resets == 0); -} - -TEST_CASE("a stretch in which every reading is 2 % off does not end the memory scan") { - // Measured on the reference card (hardware check 95): for tens of seconds - // at a time every reading is about 1.8 % higher, whatever the offset, and - // then it is back. The readings of one step are all inside such a - // stretch, so reading it three times changes nothing. - for (const double off : {0.98, 1.02}) { - Run run = rising_to_the_edge(); - run.card.bw_noise = [&card = run.card, off](int, int) { return card.bw_measurements > 20 && card.bw_measurements <= 45 ? off : 1.0; }; - const auto r = run.go(Preset::BestOfMyGpu); - CAPTURE(off); - REQUIRE(r.ok); - CHECK(run.card.max_mem_seen == 2550); - // The stretch can leave a few steps that read as high as the top of - // the scan; the tie rule then keeps the lowest of them. - CHECK(r.mem_max_stable >= 2000); - } -} - -namespace { - -// The run after: the same card and journal, the counters of the first run gone. -OptimizeResult again(Run& run) { - run.log.clear(); - run.card.max_mem_seen = 0; - run.card.bw_measurements = 0; - run.card.bw_readings_at.clear(); - run.card.bw_again_measurements = 0; - run.card.max_again_mem = -1; - return run.go(Preset::BestOfMyGpu); -} - -} - -TEST_CASE("a memory scan that ran into a wrong result leaves the edge in the journal") { - Run run = rising_to_the_edge(); - REQUIRE(run.go(Preset::BestOfMyGpu).ok); - CHECK(run.card.max_mem_seen == 2550); - CHECK(run.card.count("\"mem_edge\":2550") == 1); - CHECK(run.card.count("card-a / 617.42") == 1); - check_journal_rules(run); -} - -TEST_CASE("the next run stays four steps below the memory edge of the run before") { - // On the reference card every scan ended in a driver reset at the edge - // (hardware check 95). Once is enough. - Run run = rising_to_the_edge(); - const auto first = run.go(Preset::BestOfMyGpu); - REQUIRE(first.ok); - const auto second = again(run); - REQUIRE(second.ok); - CHECK(run.card.max_mem_seen == 2550 - kResetBackoffSteps * 50); - CHECK(run.logged("mem: the memory gave out at +2550 in an earlier run on this card and driver; the scan stops at +2350")); - CHECK_FALSE(run.logged("mem +2400")); // no candidate above it, so none that could fail - CHECK(second.driver_resets == 0); - // The same offset as the run that found the edge: the margin counts from - // the edge, not a second time from where the scan was held. - CHECK(run.card.max_again_mem == 2300); - CHECK(second.mem_max_stable == first.mem_max_stable); - CHECK(second.mem_max_stable == 2200); - CHECK(second.mem_mhz == 1500); - CHECK(run.card.count("\"mem_edge\"") == 1); // nothing new was found - // And the run after that is the same. - const auto third = again(run); - REQUIRE(third.ok); - CHECK(third.mem_mhz == second.mem_mhz); -} - -TEST_CASE("a bandwidth measurement that lost the device leaves the edge too, and the next run has no reset") { - Run run = recovering(); - run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; - run.card.bw_fails_once_at_mem = 500; - const auto first = run.go(Preset::BestOfMyGpu); - REQUIRE(first.ok); - CHECK(first.driver_resets == 1); - CHECK(run.card.count("\"mem_edge\":500") == 1); - const auto second = again(run); - REQUIRE(second.ok); - CHECK(second.driver_resets == 0); - CHECK(run.card.max_mem_seen == 300); - CHECK(second.mem_max_stable == 250); // the margin counts from the edge at +500, once -} - -TEST_CASE("the memory edge lapses with another driver") { - Run run = rising_to_the_edge(); - REQUIRE(run.go(Preset::BestOfMyGpu).ok); - run.rig = "card-a / 620.10"; - REQUIRE(again(run).ok); - CHECK(run.card.max_mem_seen == 2550); // searched afresh - CHECK_FALSE(run.logged("in an earlier run")); - CHECK(run.card.count("\"mem_edge\":2550") == 2); // and found again, for the new driver -} - -TEST_CASE("a scan that ended for another reason leaves no memory edge") { - { // a bandwidth peak - Run run; - run.card.mem_edge = 1400; - run.card.bw_curve = [](int m) { return m <= 900 ? 500 + m * 0.3 : 770 - (m - 900) * 0.6; }; - REQUIRE(run.go(Preset::BestOfMyGpu).ok); - CHECK(run.card.count("mem_edge") == 0); - } - { // readings that did not settle - Run run = recovering(); - run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; - run.card.bw_unsettled_at_mem = 300; - REQUIRE(run.go(Preset::BestOfMyGpu).ok); - CHECK(run.card.count("mem_edge") == 0); - } - { // a measurement that does not work on this machine - Run run = recovering(); - run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; - run.card.bw_fails_at_zero = true; - REQUIRE(run.go(Preset::BestOfMyGpu).ok); - CHECK(run.card.count("mem_edge") == 0); - } - { // the driver's own range - Run run = rising_to_the_edge(5000); - REQUIRE(run.go(Preset::BestOfMyGpu).ok); - CHECK(run.card.max_mem_seen == 3000); - CHECK(run.card.count("mem_edge") == 0); - } -} - -namespace { - -// The reference RTX 4070: bandwidth rises by 0.4 % a step until the memory -// gives out, and the stress load computes right up to +2500 and wrong at -// +2550. Nothing announces that edge (hardware checks 94 and 95). -Run reference_card() { - Run run = recovering(); - run.card.mem_edge = 2500; - run.card.ranges = ClockOffsetRanges{{-500, 1000}, {-1000, 3000}}; - run.card.bw_curve = [](int m) { return 434.0 * (1 + 0.004 * m / 50); }; - return run; -} - -} - -TEST_CASE("on a card whose bandwidth rises to the edge, the offset is read again well below it") { - Run run = reference_card(); - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - // The scan finds the end with the short copy and the stress load. - CHECK(run.card.max_mem_seen == 2550); - CHECK(run.logged("core +0 / mem +2550: WRONG RESULT")); - CHECK(run.logged("mem: the scan passed +2500; reading again from +2300 down")); - // The second pass: four readings, none above +2300. - CHECK(run.card.max_again_mem == 2300); - CHECK(run.card.bw_readings_at[2300] == 2); // once going up, once coming down - CHECK(run.card.bw_again_measurements == 4); - CHECK(run.logged("mem +2300: bandwidth 513.9 GB/s, read again")); - CHECK(r.driver_resets == 0); - CHECK(run.card.recover_calls == 0); - CHECK_FALSE(run.logged("treated as a driver reset")); - CHECK(run.logged("mem: bandwidth peak +2200, confirmed +2200, applying +1500")); - CHECK(r.mem_mhz == 1500); - // The edge is written down, and the next run does not even meet the wrong result. - CHECK(run.card.count("\"mem_edge\":2550") == 1); - const auto second = again(run); - REQUIRE(second.ok); - CHECK(run.card.max_mem_seen == 2350); - CHECK(run.card.max_again_mem == 2300); - CHECK(second.driver_resets == 0); - CHECK(second.mem_mhz == 1500); - check_journal_rules(run); -} - -TEST_CASE("the second pass stays below the margin on that card whatever the readings do") { - // Readings that scatter as the worst series measured next to another - // program, in both passes, with a high and a low stretch thrown in. - for (unsigned seed = 1; seed <= 40; ++seed) { - Run run = reference_card(); - const auto noise = scatter(seed, 0.013); - run.card.bw_noise = [&card = run.card, noise, seed](int mem, int nth) { - const int n = card.bw_measurements; - const double level = n > 10 && n <= 20 ? 1.018 : n > 30 + static_cast(seed % 10) && n <= 45 ? 0.982 : 1.0; - return noise(mem, nth) * level; - }; - const auto r = run.go(Preset::BestOfMyGpu); - CAPTURE(seed); - REQUIRE(r.ok); - CHECK(run.card.max_again_mem <= run.card.max_mem_seen - 50 - kPeakMarginSteps * 50); - CHECK(r.driver_resets == 0); - CHECK(run.card.max_mem_seen == 2550); - CHECK(r.mem_mhz >= 1400); // +2050 or higher kept - } -} - -TEST_CASE("the margin holds for an edge anywhere") { - // The same kind of card with its edge elsewhere. - for (int edge = 300; edge <= 2900; edge += 150) { - Run run = reference_card(); - run.card.mem_edge = edge; - const auto r = run.go(Preset::BestOfMyGpu); - CAPTURE(edge); - REQUIRE(r.ok); - CHECK(run.card.max_mem_seen == edge + 50); - CHECK(run.card.max_again_mem == edge - 200); - CHECK(r.driver_resets == 0); - CHECK(r.mem_max_stable <= edge - 200); - } -} - -TEST_CASE("a reading of the second pass that loses the device is a reset the search recovers from") { - // A card that is worse than the margin allows for. The run goes on as - // after any reset in the memory search, and the offset is remembered. - Run run = reference_card(); - run.card.bw_again_loses_device_from = 2300; - const auto r = run.go(Preset::BestOfMyGpu); - REQUIRE(r.ok); - CHECK(r.driver_resets == 1); - CHECK(run.card.bw_again_measurements == 1); - CHECK(run.logged("mem +2300: the bandwidth measurement failed; treated as a driver reset")); - CHECK(r.mem_max_stable == 2300); - CHECK(r.mem_confirmed == 2300 - kResetBackoffSteps * 50); - CHECK(run.card.max_core_seen > 0); // the core is still searched - CHECK(run.card.count("\"mem_edge\":2300") == 1); - // The next run stays below that as well, with both copies. - const auto second = again(run); - REQUIRE(second.ok); - CHECK(second.driver_resets == 0); - CHECK(run.card.max_mem_seen == 2100); - CHECK(run.card.max_again_mem == 2050); - check_journal_rules(run); -} - -TEST_CASE("the temperature ceiling of a run is the profile's limit, or what the card reached at stock, and never more than 85 C") { - CHECK(temp_ceiling(75, 67) == 75); // the reference card: nothing changes - CHECK(temp_ceiling(75, 75) == 75); - CHECK(temp_ceiling(75, 80) == 80); - CHECK(temp_ceiling(75, 86) == 85); // the tester's RTX 3080 Ti - CHECK(temp_ceiling(83, 86) == 85); - CHECK(temp_ceiling(75, -1) == 75); // no reading - CHECK(temp_ceiling(65, 70) == 70); -} - -#include "core/fan_curve.hpp" -TEST_CASE("the card's own temperature target is the ceiling's top, where the card reports one") { - // The card throttles itself at its target: a run has no business above it. - CHECK(temp_ceiling(75, 86, 83) == 83); // an RTX 3080 Ti at its default - CHECK(temp_ceiling(75, 86, 77) == 77); // the same card with the limit its owner set - CHECK(temp_ceiling(75, 80, 83) == 80); // what the card reached, when that is less - CHECK(temp_ceiling(75, 67, 84) == 75); // the reference card: nothing changes - // A target above 85 C does not raise the top: the measurement at stock - // stops there, so nothing above it was ever seen on this card. - CHECK(temp_ceiling(75, 86, 88) == 85); - CHECK(temp_ceiling(75, 86, 91) == 85); -} - -TEST_CASE("a card that reports no temperature target keeps 85 C, exactly as before") { - for (const int limit : {65, 75, 80, 83}) - for (int peak = -1; peak <= 95; ++peak) { - CAPTURE(limit); - CAPTURE(peak); - CHECK(temp_ceiling(limit, peak, 0) == temp_ceiling(limit, peak)); - CHECK(temp_ceiling(limit, peak) == std::max(limit, std::min(peak, kStockTempC))); - CHECK(temp_ceiling(limit, peak, -1) == temp_ceiling(limit, peak)); - } -} - -TEST_CASE("a target below the profile's own limit does not lower that limit") { - // The profile's limit is what the profile was asked for; a card that - // throttles below it does so by itself, as it did before. - CHECK(temp_ceiling(75, 86, 70) == 75); - CHECK(temp_ceiling(83, 86, 77) == 83); - CHECK(temp_ceiling(75, 60, 70) == 75); -} - -TEST_CASE("the fan curve moves by what the card's own target leaves, not by a fixed ten degrees") { - const FanCurve normal = fan_preset_curve(FanPreset::Normal); - // Target 83 C: the red line and the last point at 83. - CHECK(shifted_curve(normal, temp_ceiling(75, 86, 83) - 75) == FanCurve{50, {{50, 35}, {68, 45}, {78, 65}, {83, 100}}}); - // Target 77 C, set by the owner: two degrees, and the fans are at 100 % where the card would begin to slow down. - CHECK(shifted_curve(normal, temp_ceiling(75, 86, 77) - 75) == FanCurve{50, {{50, 35}, {62, 45}, {72, 65}, {77, 100}}}); - // No target reported: ten degrees, as in the step before this one. - CHECK(shifted_curve(normal, temp_ceiling(75, 86) - 75) == FanCurve{50, {{50, 35}, {70, 45}, {80, 65}, {85, 100}}}); -} diff --git a/tests/test_search_bandwidth.cpp b/tests/test_search_bandwidth.cpp new file mode 100644 index 0000000..b8d12ad --- /dev/null +++ b/tests/test_search_bandwidth.cpp @@ -0,0 +1,521 @@ +#include "doctest/doctest.h" +#include "search_fixture.hpp" + +TEST_CASE("the bandwidth scan ends at the first step that has clearly lost bandwidth") { + int max_sampled = -1; + const MemScanEnd end = bandwidth_scan(0, 1500, 50, INT_MAX, [&](int v) { + max_sampled = std::max(max_sampled, v); + return MemSample{true, v <= 600 ? 500 + v * 0.3 : 680 - (v - 600) * 0.9}; + }); + CHECK(end.why == MemScanEnd::Why::Dropped); + // The best is the middle of three steps, 665 GB/s around the peak at + // +600: +650 is 4.5 % below it, +700 is 11 %. It was stable, so it passed. + CHECK(end.top == 700); + CHECK(max_sampled == 700); +} + +TEST_CASE("a single low bandwidth reading does not end the scan; three do") { + // A step that is further from the best than readings scatter is read + // kBandwidthReadings times and its middle reading counts. One dip (measured + // on the 4070: a reading at half speed, the memory clock still in a lower + // P-state) ends nothing; a drop that is there every time does. + std::map calls; + MemScanEnd end = bandwidth_scan(0, 1500, 50, INT_MAX, [&](int v) { + const bool dip = v == 300 && calls[v] == 0; + ++calls[v]; + return MemSample{true, dip ? 250.0 : 500 + v * 0.1}; + }); + CHECK(end.why == MemScanEnd::Why::Limit); + CHECK(end.top == 1500); + CHECK(calls[250] == 1); // nothing in doubt: one reading + CHECK(calls[300] == 3); + CHECK(calls[350] == 1); + + // Two low readings of three are the middle one. + calls.clear(); + end = bandwidth_scan(0, 1500, 50, INT_MAX, [&](int v) { + const int nth = calls[v]++; + return MemSample{true, v == 300 && nth < 2 ? 250.0 : 500 + v * 0.1}; + }); + CHECK(end.why == MemScanEnd::Why::Dropped); + CHECK(end.top == 300); + CHECK(calls[300] == 3); + CHECK(calls.count(350) == 0); + + // A step that stops being stable while it is read again ends the scan below it. + calls.clear(); + end = bandwidth_scan(0, 1500, 50, INT_MAX, [&](int v) { + const int nth = calls[v]++; + return MemSample{!(v == 300 && nth == 1), v == 300 ? 250.0 : 500 + v * 0.1}; + }); + CHECK(end.why == MemScanEnd::Why::Failed); + CHECK(end.top == 250); + CHECK(calls[300] == 2); +} + +TEST_CASE("a single high bandwidth reading does not become the best the scan is held against") { + // The run that saved +100 MHz on a card that runs every game at +1500: one + // reading came out far above its neighbours, and the steps after it were + // "a drop". + std::map calls; + const MemScanEnd end = bandwidth_scan(0, 1500, 50, INT_MAX, [&](int v) { + const bool spike = v == 200 && calls[v] == 0; + ++calls[v]; + return MemSample{true, (500 + v * 0.1) * (spike ? 1.08 : 1.0)}; + }); + CHECK(end.why == MemScanEnd::Why::Limit); + CHECK(end.top == 1500); + for (const auto& [v, n] : calls) CHECK(n == 1); // and no step after it was in doubt + + // Two high steps in a row are the middle of three, and so a best: what + // comes after them is that far below it, read three times, and ends the scan. + const MemScanEnd two = bandwidth_scan(0, 1500, 50, INT_MAX, [&](int v) { + return MemSample{true, (500 + v * 0.1) * (v == 200 || v == 250 ? 1.08 : 1.0)}; + }); + CHECK(two.why == MemScanEnd::Why::Dropped); + CHECK(two.top == 300); +} + +TEST_CASE("the bandwidth scan ends below the first unstable step") { + int max_sampled = -1; + const MemScanEnd end = bandwidth_scan(0, 1500, 50, INT_MAX, [&](int v) { + max_sampled = std::max(max_sampled, v); + return MemSample{v <= 400, 500 + v * 0.1}; + }); + CHECK(end.why == MemScanEnd::Why::Failed); + CHECK(end.top == 400); + CHECK(max_sampled == 450); +} + +TEST_CASE("the bandwidth scan samples lo and respects the ceiling") { + std::vector sampled; + const MemScanEnd end = bandwidth_scan(0, 1500, 50, 300, [&](int v) { + sampled.push_back(v); + return MemSample{true, 500 + v * 0.1}; + }); + REQUIRE_FALSE(sampled.empty()); + CHECK(sampled.front() == 0); + CHECK(sampled.back() == 250); + CHECK(end.top == 250); + CHECK(end.why == MemScanEnd::Why::Limit); + const MemScanEnd none = bandwidth_scan(0, 1500, 50, 300, [](int) { return MemSample{false, 0}; }); + CHECK(none.top == 0); + CHECK(none.why == MemScanEnd::Why::Failed); +} + +TEST_CASE("the second pass begins four steps below the highest step the scan passed") { + using Why = MemScanEnd::Why; + // The step above +2500 failed: +2500 passed, so +2300. + CHECK(second_pass_top({2500, Why::Failed}, 0, 50, INT_MAX) == 2300); + // +1000 passed and had lost bandwidth: +800. + CHECK(second_pass_top({1000, Why::Dropped}, 0, 50, INT_MAX) == 800); + // The driver's range, or the ceiling of a freeze: nothing failed, and the + // margin is kept all the same. What is above was never tried. + CHECK(second_pass_top({3000, Why::Limit}, 0, 50, INT_MAX) == 2800); + // Never below lo, and a scan that passed nothing reads nothing above it. + CHECK(second_pass_top({150, Why::Failed}, 0, 50, INT_MAX) == 0); + CHECK(second_pass_top({0, Why::Failed}, 0, 50, INT_MAX) == 0); +} + +TEST_CASE("a scan held below a known edge counts the margin from that edge, once") { + using Why = MemScanEnd::Why; + // The run that found the edge at +2550 read from +2300. The run after it is + // held at +2350 and reads from +2300 as well, not from +2150. + CHECK(second_pass_top({2500, Why::Failed}, 0, 50, INT_MAX) == 2300); + CHECK(second_pass_top({2350, Why::Limit}, 0, 50, 2550) == 2300); + // A step that failed below the known edge counts, not the edge. + CHECK(second_pass_top({2000, Why::Failed}, 0, 50, 2550) == 1800); + CHECK(second_pass_top({2000, Why::Dropped}, 0, 50, 2550) == 1800); + // An edge that is no higher than where the scan got changes nothing. + CHECK(second_pass_top({2350, Why::Limit}, 0, 50, 2400) == 2150); +} + +TEST_CASE("the second pass goes down from the top and keeps the lowest offset within the tie") { + // A curve that still rises at the top: 0.4 % a step. + std::vector sampled; + int r = bandwidth_peak_below(2300, 0, 50, [&](int v) { + sampled.push_back(v); + return MemSample{true, 434.0 * (1 + 0.004 * v / 50)}; + }); + CHECK(r == 2200); + CHECK(sampled == std::vector{2300, 2250, 2200, 2150}); + + // A peak below the top: the readings rise on the way down, then fall. + sampled.clear(); + r = bandwidth_peak_below(1200, 0, 50, [&](int v) { + sampled.push_back(v); + return MemSample{true, v <= 900 ? 500 + v * 0.3 : 770 - (v - 900) * 0.6}; + }); + CHECK(r == 900); + CHECK(sampled.front() == 1200); + CHECK(sampled.back() == 850); // 1.9 % below the peak: the pass ends + + // A flat curve keeps the lowest offset there is. + CHECK(bandwidth_peak_below(400, 0, 50, [](int v) { return MemSample{true, 500.0 + ((v / 50) % 2)}; }) == 0); + // Readings that scatter by twice what they do on a quiet 4070 leave a flat + // curve at the bottom. + CHECK(bandwidth_peak_below(400, 0, 50, [](int v) { return MemSample{true, 500.0 * (1 + (((v / 50) % 3) - 1) * 0.004)}; }) == 0); +} + +TEST_CASE("the second pass keeps what it has when a step is not stable") { + // Not one sample: the top it was given. + CHECK(bandwidth_peak_below(2300, 0, 50, [](int) { return MemSample{false, 0}; }) == 2300); + // One sample, then none: that one. + int n = 0; + CHECK(bandwidth_peak_below(2300, 0, 50, [&](int v) { return MemSample{n++ == 0, 500.0 + v * 0.1}; }) == 2300); + // Never below lo. + std::vector sampled; + CHECK(bandwidth_peak_below(100, 0, 50, [&](int v) { sampled.push_back(v); return MemSample{true, 500.0}; }) == 0); + CHECK(sampled == std::vector{100, 50, 0}); +} + +namespace { + +// A card like the reference RTX 4070 (hardware check 94): the bandwidth rises +// by 0.4 % per 50 MHz step all the way to the first wrong result. There is no +// peak to find; only scatter can end the scan before the edge. +Run rising_to_the_edge(int edge = 2500) { + Run run; + run.card.mem_edge = edge; + run.card.ranges = ClockOffsetRanges{{-500, 1000}, {-1000, 3000}}; + run.card.bw_curve = [](int m) { return 434.0 * (1 + 0.004 * m / 50); }; + return run; +} + +// Scatter with a standard deviation of about `sd`, the same for the same +// (seed, offset, reading): three uniform numbers summed, which is bell-shaped +// enough and needs no library whose numbers differ between compilers. +std::function scatter(unsigned seed, double sd) { + return [seed, sd](int mem, int nth) { + unsigned x = seed * 2654435761u + static_cast(mem) * 40503u + static_cast(nth) * 9176u + 12345u; + double sum = 0; + for (int i = 0; i < 3; ++i) { + x ^= x << 13; x ^= x >> 17; x ^= x << 5; + sum += (x % 100000) / 100000.0 - 0.5; // uniform in -0.5 .. 0.5, variance 1/12 + } + return 1.0 + sum * 2.0 * sd; // three of them: variance 1/4, so a deviation of sd + }; +} + +} + +TEST_CASE("one bandwidth reading that is far too high does not end the memory scan") { + // The run that saved +100 MHz on a card that runs every game at +1500: + // one reading came out far above its neighbours, the two after it were + // under it, and that was "the peak". + Run run = rising_to_the_edge(); + run.card.bw_noise = [](int mem, int nth) { return mem == 200 && nth == 0 ? 1.08 : 1.0; }; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(run.card.max_mem_seen == 2550); // the scan went to the first wrong result + CHECK(run.logged("mem +0: bandwidth 434.0 GB/s")); + CHECK(run.card.bw_measurements == 51); // +0 .. +2500, each once: no step was in doubt + // +2500 passed; the second pass reads from +2300 down, and +2200 is the + // lowest offset within 1 % of what it read there. + CHECK(r.mem_max_stable == 2200); + CHECK(r.mem_mhz == 1500); +} + +TEST_CASE("one low bandwidth reading does not end the memory scan; three low ones do") { + { + Run run = rising_to_the_edge(); + run.card.bw_noise = [](int mem, int nth) { return mem == 1000 && nth == 0 ? 0.9 : 1.0; }; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(run.card.bw_readings_at[950] == 1); + CHECK(run.card.bw_readings_at[1000] == kBandwidthReadings); + CHECK(run.card.max_mem_seen == 2550); + CHECK(r.mem_max_stable == 2200); + } + { + // From +1000 on the memory has lost 8 %: that is a peak, whichever copy reads it. + Run run = rising_to_the_edge(); + run.card.bw_curve = [](int m) { return 434.0 * (1 + 0.004 * m / 50) * (m >= 1000 ? 0.92 : 1.0); }; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(run.card.bw_readings_at[1000] == kBandwidthReadings); + CHECK(run.logged("mem: the scan passed +1000; reading again from +800 down")); + CHECK(run.card.max_mem_seen == 1000); + CHECK(run.card.max_again_mem == 800); + CHECK(r.mem_max_stable == 700); // the lowest offset within 1 % of the reading at +800 + } +} + +TEST_CASE("readings that scatter as they do on the reference card leave the memory scan where the card puts it") { + // Measured there: readings scatter by 0.2 % on a card that is left alone + // (hardware check 96). A little more than that here, on two hundred + // different days. Every scan gets to the first wrong result, and the + // offset that is kept moves by five steps: +2050 to +2300. + for (unsigned seed = 1; seed <= 200; ++seed) { + Run run = rising_to_the_edge(); + run.card.bw_noise = scatter(seed, 0.0025); + const auto r = run.go(Preset::BestOfMyGpu); + CAPTURE(seed); + REQUIRE(r.ok); + CHECK(run.card.max_mem_seen == 2550); + CHECK(run.card.max_again_mem == 2300); + CHECK(r.mem_max_stable >= 2050); + CHECK(r.mem_max_stable <= 2300); + } +} + +TEST_CASE("a real peak is still found where it is when the readings scatter") { + // The card the method was made for: bandwidth turns down long before the + // first wrong result. Scatter must not carry the scan past the peak. + for (unsigned seed = 1; seed <= 20; ++seed) { + Run run; + run.card.mem_edge = 1400; + run.card.bw_curve = [](int m) { return m <= 900 ? 500 + m * 0.3 : 770 - (m - 900) * 0.6; }; + run.card.bw_noise = scatter(seed, 0.013); + const auto r = run.go(Preset::BestOfMyGpu); + CAPTURE(seed); + REQUIRE(r.ok); + // +950 is 3.9 % below the peak and +1000 7.8 %: with this scatter the + // scan ends at one of the two, now and then one step later. + CHECK(run.card.max_mem_seen >= 950); + CHECK(run.card.max_mem_seen <= 1050); + CHECK(run.card.max_again_mem <= run.card.max_mem_seen - kPeakMarginSteps * 50); + CHECK(r.mem_max_stable >= 700); + CHECK(r.mem_max_stable <= 900); + } +} + +TEST_CASE("a reading that does not settle is taken again") { + // On the reference card one reading in twenty-four did not settle, with + // nothing wrong. One such reading used to end the scan. + Run run = recovering(); + run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; + run.card.bw_unsettled_once_at_mem = 300; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK_FALSE(run.logged("did not settle")); + CHECK(run.logged("mem +300: bandwidth 590.0 GB/s")); + CHECK(run.card.bw_readings_at[300] == 2); + CHECK(run.card.max_mem_seen > 300); + CHECK(r.driver_resets == 0); +} + +TEST_CASE("a stretch in which every reading is 2 % off does not end the memory scan") { + // Measured on the reference card (hardware check 95): for tens of seconds + // at a time every reading is about 1.8 % higher, whatever the offset, and + // then it is back. The readings of one step are all inside such a + // stretch, so reading it three times changes nothing. + for (const double off : {0.98, 1.02}) { + Run run = rising_to_the_edge(); + run.card.bw_noise = [&card = run.card, off](int, int) { return card.bw_measurements > 20 && card.bw_measurements <= 45 ? off : 1.0; }; + const auto r = run.go(Preset::BestOfMyGpu); + CAPTURE(off); + REQUIRE(r.ok); + CHECK(run.card.max_mem_seen == 2550); + // The stretch can leave a few steps that read as high as the top of + // the scan; the tie rule then keeps the lowest of them. + CHECK(r.mem_max_stable >= 2000); + } +} + +namespace { + +// The run after: the same card and journal, the counters of the first run gone. +OptimizeResult again(Run& run) { + run.log.clear(); + run.card.max_mem_seen = 0; + run.card.bw_measurements = 0; + run.card.bw_readings_at.clear(); + run.card.bw_again_measurements = 0; + run.card.max_again_mem = -1; + return run.go(Preset::BestOfMyGpu); +} + +} + +TEST_CASE("a memory scan that ran into a wrong result leaves the edge in the journal") { + Run run = rising_to_the_edge(); + REQUIRE(run.go(Preset::BestOfMyGpu).ok); + CHECK(run.card.max_mem_seen == 2550); + CHECK(run.card.count("\"mem_edge\":2550") == 1); + CHECK(run.card.count("card-a / 617.42") == 1); + check_journal_rules(run); +} + +TEST_CASE("the next run stays four steps below the memory edge of the run before") { + // On the reference card every scan ended in a driver reset at the edge + // (hardware check 95). Once is enough. + Run run = rising_to_the_edge(); + const auto first = run.go(Preset::BestOfMyGpu); + REQUIRE(first.ok); + const auto second = again(run); + REQUIRE(second.ok); + CHECK(run.card.max_mem_seen == 2550 - kResetBackoffSteps * 50); + CHECK(run.logged("mem: the memory gave out at +2550 in an earlier run on this card and driver; the scan stops at +2350")); + CHECK_FALSE(run.logged("mem +2400")); // no candidate above it, so none that could fail + CHECK(second.driver_resets == 0); + // The same offset as the run that found the edge: the margin counts from + // the edge, not a second time from where the scan was held. + CHECK(run.card.max_again_mem == 2300); + CHECK(second.mem_max_stable == first.mem_max_stable); + CHECK(second.mem_max_stable == 2200); + CHECK(second.mem_mhz == 1500); + CHECK(run.card.count("\"mem_edge\"") == 1); // nothing new was found + // And the run after that is the same. + const auto third = again(run); + REQUIRE(third.ok); + CHECK(third.mem_mhz == second.mem_mhz); +} + +TEST_CASE("a bandwidth measurement that lost the device leaves the edge too, and the next run has no reset") { + Run run = recovering(); + run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; + run.card.bw_fails_once_at_mem = 500; + const auto first = run.go(Preset::BestOfMyGpu); + REQUIRE(first.ok); + CHECK(first.driver_resets == 1); + CHECK(run.card.count("\"mem_edge\":500") == 1); + const auto second = again(run); + REQUIRE(second.ok); + CHECK(second.driver_resets == 0); + CHECK(run.card.max_mem_seen == 300); + CHECK(second.mem_max_stable == 250); // the margin counts from the edge at +500, once +} + +TEST_CASE("the memory edge lapses with another driver") { + Run run = rising_to_the_edge(); + REQUIRE(run.go(Preset::BestOfMyGpu).ok); + run.rig = "card-a / 620.10"; + REQUIRE(again(run).ok); + CHECK(run.card.max_mem_seen == 2550); // searched afresh + CHECK_FALSE(run.logged("in an earlier run")); + CHECK(run.card.count("\"mem_edge\":2550") == 2); // and found again, for the new driver +} + +TEST_CASE("a scan that ended for another reason leaves no memory edge") { + { // a bandwidth peak + Run run; + run.card.mem_edge = 1400; + run.card.bw_curve = [](int m) { return m <= 900 ? 500 + m * 0.3 : 770 - (m - 900) * 0.6; }; + REQUIRE(run.go(Preset::BestOfMyGpu).ok); + CHECK(run.card.count("mem_edge") == 0); + } + { // readings that did not settle + Run run = recovering(); + run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; + run.card.bw_unsettled_at_mem = 300; + REQUIRE(run.go(Preset::BestOfMyGpu).ok); + CHECK(run.card.count("mem_edge") == 0); + } + { // a measurement that does not work on this machine + Run run = recovering(); + run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; + run.card.bw_fails_at_zero = true; + REQUIRE(run.go(Preset::BestOfMyGpu).ok); + CHECK(run.card.count("mem_edge") == 0); + } + { // the driver's own range + Run run = rising_to_the_edge(5000); + REQUIRE(run.go(Preset::BestOfMyGpu).ok); + CHECK(run.card.max_mem_seen == 3000); + CHECK(run.card.count("mem_edge") == 0); + } +} + +namespace { + +// The reference RTX 4070: bandwidth rises by 0.4 % a step until the memory +// gives out, and the stress load computes right up to +2500 and wrong at +// +2550. Nothing announces that edge (hardware checks 94 and 95). +Run reference_card() { + Run run = recovering(); + run.card.mem_edge = 2500; + run.card.ranges = ClockOffsetRanges{{-500, 1000}, {-1000, 3000}}; + run.card.bw_curve = [](int m) { return 434.0 * (1 + 0.004 * m / 50); }; + return run; +} + +} + +TEST_CASE("on a card whose bandwidth rises to the edge, the offset is read again well below it") { + Run run = reference_card(); + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + // The scan finds the end with the short copy and the stress load. + CHECK(run.card.max_mem_seen == 2550); + CHECK(run.logged("core +0 / mem +2550: WRONG RESULT")); + CHECK(run.logged("mem: the scan passed +2500; reading again from +2300 down")); + // The second pass: four readings, none above +2300. + CHECK(run.card.max_again_mem == 2300); + CHECK(run.card.bw_readings_at[2300] == 2); // once going up, once coming down + CHECK(run.card.bw_again_measurements == 4); + CHECK(run.logged("mem +2300: bandwidth 513.9 GB/s, read again")); + CHECK(r.driver_resets == 0); + CHECK(run.card.recover_calls == 0); + CHECK_FALSE(run.logged("treated as a driver reset")); + CHECK(run.logged("mem: bandwidth peak +2200, confirmed +2200, applying +1500")); + CHECK(r.mem_mhz == 1500); + // The edge is written down, and the next run does not even meet the wrong result. + CHECK(run.card.count("\"mem_edge\":2550") == 1); + const auto second = again(run); + REQUIRE(second.ok); + CHECK(run.card.max_mem_seen == 2350); + CHECK(run.card.max_again_mem == 2300); + CHECK(second.driver_resets == 0); + CHECK(second.mem_mhz == 1500); + check_journal_rules(run); +} + +TEST_CASE("the second pass stays below the margin on that card whatever the readings do") { + // Readings that scatter as the worst series measured next to another + // program, in both passes, with a high and a low stretch thrown in. + for (unsigned seed = 1; seed <= 40; ++seed) { + Run run = reference_card(); + const auto noise = scatter(seed, 0.013); + run.card.bw_noise = [&card = run.card, noise, seed](int mem, int nth) { + const int n = card.bw_measurements; + const double level = n > 10 && n <= 20 ? 1.018 : n > 30 + static_cast(seed % 10) && n <= 45 ? 0.982 : 1.0; + return noise(mem, nth) * level; + }; + const auto r = run.go(Preset::BestOfMyGpu); + CAPTURE(seed); + REQUIRE(r.ok); + CHECK(run.card.max_again_mem <= run.card.max_mem_seen - 50 - kPeakMarginSteps * 50); + CHECK(r.driver_resets == 0); + CHECK(run.card.max_mem_seen == 2550); + CHECK(r.mem_mhz >= 1400); // +2050 or higher kept + } +} + +TEST_CASE("the margin holds for an edge anywhere") { + // The same kind of card with its edge elsewhere. + for (int edge = 300; edge <= 2900; edge += 150) { + Run run = reference_card(); + run.card.mem_edge = edge; + const auto r = run.go(Preset::BestOfMyGpu); + CAPTURE(edge); + REQUIRE(r.ok); + CHECK(run.card.max_mem_seen == edge + 50); + CHECK(run.card.max_again_mem == edge - 200); + CHECK(r.driver_resets == 0); + CHECK(r.mem_max_stable <= edge - 200); + } +} + +TEST_CASE("a reading of the second pass that loses the device is a reset the search recovers from") { + // A card that is worse than the margin allows for. The run goes on as + // after any reset in the memory search, and the offset is remembered. + Run run = reference_card(); + run.card.bw_again_loses_device_from = 2300; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 1); + CHECK(run.card.bw_again_measurements == 1); + CHECK(run.logged("mem +2300: the bandwidth measurement failed; treated as a driver reset")); + CHECK(r.mem_max_stable == 2300); + CHECK(r.mem_confirmed == 2300 - kResetBackoffSteps * 50); + CHECK(run.card.max_core_seen > 0); // the core is still searched + CHECK(run.card.count("\"mem_edge\":2300") == 1); + // The next run stays below that as well, with both copies. + const auto second = again(run); + REQUIRE(second.ok); + CHECK(second.driver_resets == 0); + CHECK(run.card.max_mem_seen == 2100); + CHECK(run.card.max_again_mem == 2050); + check_journal_rules(run); +} diff --git a/tests/test_search_bounds.cpp b/tests/test_search_bounds.cpp new file mode 100644 index 0000000..69e7695 --- /dev/null +++ b/tests/test_search_bounds.cpp @@ -0,0 +1,255 @@ +#include "doctest/doctest.h" +#include "search_fixture.hpp" + +TEST_CASE("climb_to_edge climbs in strides, then refines on the step grid") { + std::vector probed; + const int r = climb_to_edge(0, 300, 15, 60, INT_MAX, [&](int v) { probed.push_back(v); return v <= 150; }); + CHECK(r == 150); + CHECK(probed == std::vector{60, 120, 180, 150, 165}); +} + +TEST_CASE("climb_to_edge finds an edge between strides and between grid points") { + CHECK(climb_to_edge(0, 300, 15, 60, INT_MAX, [](int v) { return v <= 100; }) == 90); + CHECK(climb_to_edge(0, 300, 15, 60, INT_MAX, [](int v) { return v <= 120; }) == 120); + CHECK(climb_to_edge(0, 300, 15, 60, INT_MAX, [](int v) { return v <= 15; }) == 15); +} + +TEST_CASE("climb_to_edge never probes lo, and returns lo when nothing above holds") { + std::vector probed; + const int r = climb_to_edge(0, 300, 15, 60, INT_MAX, [&](int v) { probed.push_back(v); return false; }); + CHECK(r == 0); + CHECK(probed == std::vector{60, 30, 15}); +} + +TEST_CASE("climb_to_edge never exceeds hi, even when hi is off the grid") { + int max_probed = 0; + const int r = climb_to_edge(0, 1000, 15, 60, INT_MAX, [&](int v) { max_probed = std::max(max_probed, v); return true; }); + CHECK(r == 990); // the highest grid point at or below +1000 + CHECK(max_probed == 990); + CHECK(climb_to_edge(0, 300, 15, 60, INT_MAX, [](int) { return true; }) == 300); +} + +TEST_CASE("climb_to_edge stays below the journal ceiling") { + int max_probed = 0; + const int r = climb_to_edge(0, 300, 15, 60, 120, [&](int v) { max_probed = std::max(max_probed, v); return true; }); + CHECK(r == 105); + CHECK(max_probed == 105); + int calls = 0; + CHECK(climb_to_edge(0, 300, 15, 60, 0, [&](int) { ++calls; return true; }) == 0); + CHECK(calls == 0); +} + +TEST_CASE("climb_to_edge never probes a value twice, and never above the first failure") { + // A marginal clock can pass one probe and fail the next. A value that + // failed is not given a second chance, and nothing above it is tried. + std::map calls; + int first_failure = INT_MAX; + climb_to_edge(0, 600, 15, 60, INT_MAX, [&](int v) { + ++calls[v]; + CHECK(v < first_failure); + const bool ok = v <= 260; + if (!ok) first_failure = std::min(first_failure, v); + return ok; + }); + for (const auto& [v, n] : calls) CHECK(n == 1); +} + +TEST_CASE("the core search probes upward from stock, never from the middle of the range") { + Run run; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.core_max_stable == 150); + CHECK(run.card.max_core_seen == 165); // one step past the edge, not +225 or +300 +} + +TEST_CASE("plausible_range accepts a normal range and rejects each kind of nonsense") { + CHECK(plausible_range({-500, 1000}, 0, 2000)); + CHECK(plausible_range({0, 1000}, 0, 2000)); // a card that allows no negative offset + CHECK(plausible_range({-500, 1000}, 210, 2000)); // a tune is applied + CHECK_FALSE(plausible_range({100, 1000}, 0, 2000)); // minimum above zero + CHECK_FALSE(plausible_range({-500, 0}, 0, 2000)); // maximum not above zero + CHECK_FALSE(plausible_range({0, 0}, 0, 2000)); // an empty buffer + CHECK_FALSE(plausible_range({-500, 1000}, 1200, 2000)); // the applied offset is outside it + CHECK_FALSE(plausible_range({-500, 1000}, -600, 2000)); + CHECK_FALSE(plausible_range({-500000, 1000000}, 0, 2000)); // kHz read as MHz + CHECK(plausible_range({-500, 2000}, 0, 2000)); // the sanity limit itself is allowed + CHECK_FALSE(plausible_range({-500, 2001}, 0, 2000)); +} + +TEST_CASE("search_bounds falls back when the card reports nothing usable") { + GpuControl none; + CHECK(search_bounds(none).core_max_mhz == 300); + CHECK(search_bounds(none).mem_max_mhz == 1500); + CHECK_FALSE(search_bounds(none).core_from_card); + + GpuControl no_read_back; // a range, but no way to check the applied offset against it + no_read_back.clock_offset_range_mhz = [] { return std::optional(ClockOffsetRanges{{-500, 1000}, {-1000, 3000}}); }; + CHECK(search_bounds(no_read_back).core_max_mhz == 300); + + GpuControl read_fails = no_read_back; + read_fails.read_applied = []() -> std::optional { return std::nullopt; }; + CHECK(search_bounds(read_fails).core_max_mhz == 300); + + GpuControl range_fails; + range_fails.clock_offset_range_mhz = []() -> std::optional { return std::nullopt; }; + range_fails.read_applied = [] { return std::optional(AppliedState{}); }; + CHECK(search_bounds(range_fails).mem_max_mhz == 1500); +} + +TEST_CASE("search_bounds takes core and memory from the card independently") { + GpuControl g; + g.read_applied = [] { return std::optional(AppliedState{}); }; + g.clock_offset_range_mhz = [] { return std::optional(ClockOffsetRanges{{-500, 1000}, {-1000, 3000}}); }; + const SearchBounds both = search_bounds(g); + CHECK(both.core_max_mhz == 1000); + CHECK(both.mem_max_mhz == 3000); + CHECK(both.core_from_card); + CHECK(both.mem_from_card); + + g.clock_offset_range_mhz = [] { return std::optional(ClockOffsetRanges{{-500, 1000}, {50, 9000}}); }; + const SearchBounds core_only = search_bounds(g); + CHECK(core_only.core_max_mhz == 1000); + CHECK(core_only.mem_max_mhz == 1500); + CHECK_FALSE(core_only.mem_from_card); +} + +TEST_CASE("a card that holds more than the old cap is searched to its own edge") { + Run run; + run.card.ranges = ClockOffsetRanges{{-500, 1000}, {-1000, 3000}}; + run.card.core_edge = 450; + run.card.mem_edge = 2000; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.core_max_stable == 450); + CHECK(r.core_confirmed == 450); + CHECK(r.core_mhz == 315); // 70 % of 450 + CHECK(r.mem_max_stable == 2000); + CHECK(r.mem_mhz == 1400); // 70 % of 2000 + CHECK(run.card.max_core_seen == 465); // one step past the edge + CHECK(run.card.max_mem_seen == 2050); + bool told = false; + for (const auto& m : run.log) told |= m.find("core range: up to +1000 MHz (reported by the card)") != std::string::npos; + CHECK(told); +} + +TEST_CASE("a card stable to the top of its reported range stops at that range") { + Run run; + run.card.ranges = ClockOffsetRanges{{-500, 400}, {-1000, 1000}}; + run.card.core_edge = 5000; + run.card.mem_edge = 5000; + const auto r = run.go(Preset::MaxPerformance); + REQUIRE(r.ok); + CHECK(r.core_max_stable == 390); // the highest 15 MHz grid point at or below +400 + CHECK(run.card.max_core_seen == 390); + CHECK(r.mem_max_stable == 1000); + CHECK(run.card.max_mem_seen == 1000); +} + +TEST_CASE("an implausible reported range leaves the built-in limit in place and says so") { + Run run; + run.card.ranges = ClockOffsetRanges{{100, 1000}, {-1000, 3000}}; // core minimum above zero + run.card.core_edge = 450; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.core_max_stable == 300); + CHECK(run.card.max_core_seen == 300); + bool told = false; + for (const auto& m : run.log) told |= m.find("core range: up to +300 MHz (built-in limit") != std::string::npos; + CHECK(told); +} + +TEST_CASE("a failing range read leaves the built-in limits in place") { + Run run; + run.card.ranges_read_fails = true; + run.card.core_edge = 450; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.core_max_stable == 300); +} + +TEST_CASE("memory is searched before the core") { + Run run; + run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + int mem_begins = 0, core_begins = 0, mem_after_core = 0; + for (const auto& l : run.card.journal) { + if (l.find("\"begin\"") == std::string::npos) continue; + const bool has_core = l.find("\"core\":") != std::string::npos; + const bool has_mem = l.find("\"mem\":") != std::string::npos; + if (has_core && !has_mem) ++core_begins; + if (has_mem && !has_core) { + ++mem_begins; + if (core_begins > 0) ++mem_after_core; + } + } + CHECK(mem_begins > 0); + CHECK(core_begins > 0); + CHECK(mem_after_core == 0); +} + +TEST_CASE("the core climb runs with the chosen memory applied") { + Run run; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.mem_mhz == 550); + // 11 core candidates, the core confirm probe and the soak. + CHECK(run.card.mem_during_core_probes.size() == 13); + for (int mem : run.card.mem_during_core_probes) CHECK(mem == r.mem_mhz); +} + +TEST_CASE("the core climb advances one step at a time") { + Run run; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + // The climb, then the one 30 s confirm probe at the edge. + CHECK(begins_with_only(run.card, "core", "mem") == + std::vector{15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 150}); + CHECK(r.core_max_stable == 150); + CHECK(run.card.max_core_seen == 165); +} + +TEST_CASE("the stability-only memory climb advances one step at a time") { + Run run; // no bw_curve: no bandwidth measurement + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + // The climb, then the one 30 s confirm probe at the edge. + CHECK(begins_with_only(run.card, "mem", "core") == + std::vector{50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 800}); + CHECK(r.mem_max_stable == 800); + CHECK(run.card.max_mem_seen == 850); +} + +TEST_CASE("every profile gives today's result on a card with no reset") { + struct Want { Preset preset; int power, core, mem; }; + const Want wants[] = {{Preset::BestOfMyGpu, 115, 105, 550}, {Preset::Quiet, 90, 60, 300}, + {Preset::MaxPerformance, 120, 135, 750}, {Preset::CoolAndEfficient, 85, 0, 0}}; + for (const auto& w : wants) { + Run run; + const auto r = run.go(w.preset); + REQUIRE(r.ok); + CHECK(r.power_pct == w.power); + CHECK(r.core_mhz == w.core); + CHECK(r.mem_mhz == w.mem); + CHECK(run.card.power == w.power); + CHECK(run.card.core == w.core); + CHECK(run.card.mem == w.mem); + } +} + +TEST_CASE("a profile without core tuning and a card without power control still run") { + Run cool; + const auto a = cool.go(Preset::CoolAndEfficient); + REQUIRE(a.ok); + CHECK(cool.card.max_core_seen == 0); + CHECK(cool.card.max_mem_seen == 0); + CHECK(cool.card.journal.empty() == false); // the soak is journaled + + Run fixed; + const auto b = fixed.go(Preset::BestOfMyGpu, fixed.card.gpu(/*with_power=*/false)); + REQUIRE(b.ok); + CHECK(fixed.card.power_probes == 0); + CHECK(b.power_pct == 100); + CHECK(b.core_mhz == 105); + CHECK(b.mem_mhz == 550); +} diff --git a/tests/test_search_recovery.cpp b/tests/test_search_recovery.cpp new file mode 100644 index 0000000..c9f49c8 --- /dev/null +++ b/tests/test_search_recovery.cpp @@ -0,0 +1,670 @@ +#include "doctest/doctest.h" +#include "search_fixture.hpp" + +TEST_CASE("a probe that passes at a fraction of the baseline score is stalled, not stable") { + // Measured on an RTX 5070: at +480 the card stopped computing, the probe + // found no wrong value and no lost device, and scored 167 against 5869. + Run run; + run.card.stall_core_from = 135; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.core_max_stable == 120); + // Without a reconnect the reset budget does not apply: the stall is + // counted, the confirm probe runs at the edge with no back-off, and the + // log does not speak of a reset. + CHECK(r.driver_resets == 1); + CHECK(r.core_confirmed == 120); + CHECK_FALSE(run.logged("the driver reset")); + bool journaled = false; + for (const auto& l : run.card.journal) journaled |= l.find("STALLED") != std::string::npos; + CHECK(journaled); + bool logged = false; + for (const auto& m : run.log) logged |= m.find("STALLED") != std::string::npos; + CHECK(logged); +} + +TEST_CASE("a low score at a low power limit is not a stall") { + // The quiet preset probes power limits down to the card's minimum; the + // fixture's score at 50 % power is 56 % of the baseline. + Run run; + const auto r = run.go(Preset::Quiet); + REQUIRE(r.ok); + for (const auto& m : run.log) CHECK(m.find("STALLED") == std::string::npos); +} + +TEST_CASE("without a reconnect a driver reset still ends the run cleanly") { + // Today's behaviour, kept for a GpuControl that cannot reconnect. + Run run; + run.card.lost_core_from = 135; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason.find("failed") != std::string::npos); + CHECK_FALSE(r.stock_restored); + Journal reread(run.card.journal, [](const std::string&) { return true; }); + CHECK(reread.freezes().empty()); +} + +TEST_CASE("a driver that does not come back ends the run without opening another journal entry") { + Run run; + run.card.with_recover = true; + run.card.recover_fails = true; + run.card.lost_core_from = 135; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the driver did not come back after a reset"); + CHECK_FALSE(r.stock_restored); + CHECK(run.card.recover_calls == 1); // a reconnect that failed is not tried again on the way out + CHECK(run.card.count("\"begin\"") == run.card.begins_when_recover_failed); // nothing was journaled after the failed reconnect + check_journal_rules(run); +} + +TEST_CASE("without a reconnect a probe without telemetry ends the run at once") { + Run run; + run.card.blind_probes = 1; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "lost telemetry"); +} + +TEST_CASE("a run stopped while the card is stale reconnects before it resets to stock") { + // Probe 32 is core +135, the lost candidate (23 probes up to the memory + // confirm, then core +15 .. +120); the stop request is seen before the + // next candidate (the confirm probe at +60) and before its recovery gate, + // with the old connections still dead. + Run run; + run.card.with_recover = true; + run.card.lost_core_from = 135; + run.abort_after_probes = 32; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "aborted"); + CHECK(run.card.probes == 32); + CHECK(run.logged("core +135 / mem +550: DEVICE LOST")); + CHECK(r.stock_restored); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + CHECK(run.card.power == 100); + CHECK_FALSE(run.card.stale); + CHECK(run.card.recover_calls == 1); + CHECK(run.card.rests == 0); + CHECK(count_events(run.card, "health") == 0); + CHECK(begins_with_only(run.card, "core", "mem") == std::vector{15, 30, 45, 60, 75, 90, 105, 120, 135}); + check_journal_rules(run); +} + +// The probes of a default best-preset run, in order: 1 baseline; 2-5 power +// (85, 105, 115, 120 %); 6-22 memory (+50, +100, ... +850); 23 memory confirm +// (+800); 24-34 core (+15, +30, ... +165); 35 core confirm (+150); 36 soak. +TEST_CASE("the probe order the driver-reset tests rely on") { + Run run; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(run.card.probes == 36); + // Each probe as "seconds:power:core:mem". + std::vector want{"30:100:0:0", "20:85:0:0", "20:105:0:0", "20:115:0:0", "20:120:0:0"}; + for (int mem = 50; mem <= 850; mem += 50) want.push_back("3:115:0:" + std::to_string(mem)); + want.push_back("30:115:0:800"); + for (int core = 15; core <= 165; core += 15) want.push_back("3:115:" + std::to_string(core) + ":550"); + want.push_back("30:115:150:550"); + want.push_back("300:115:105:550"); + REQUIRE(want.size() == 36); + CHECK(run.card.probe_order == want); +} + +TEST_CASE("a stop request during a failing reconnect is reported as aborted") { + Run run; + run.card.with_recover = true; + run.card.recover_fails = true; + run.card.abort_in_recover = true; + run.card.lost_core_from = 135; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "aborted"); + CHECK(run.card.recover_calls == 1); + check_journal_rules(run); +} + +TEST_CASE("a stale connection at the start is reconnected before the first reset") { + Run run; + run.card.with_recover = true; + run.card.stale = true; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(run.card.recover_calls == 1); + CHECK(r.core_max_stable == 150); + check_journal_rules(run); +} + +TEST_CASE("a start that cannot reset to stock even after a reconnect says so") { + Run dead; // the reconnect fails + dead.card.with_recover = true; + dead.card.recover_fails = true; + dead.card.stale = true; + const auto a = dead.go(Preset::BestOfMyGpu); + CHECK_FALSE(a.ok); + CHECK(a.reason == "could not reset to stock"); + CHECK_FALSE(a.stock_restored); + CHECK(dead.logged("reset FAILED")); + CHECK(dead.card.recover_calls == 1); + CHECK(dead.card.probes == 0); + + Run stuck; // the reconnect works, the reset keeps failing + stuck.card.with_recover = true; + stuck.card.reset_fails_from = 0; + const auto b = stuck.go(Preset::BestOfMyGpu); + CHECK_FALSE(b.ok); + CHECK(b.reason == "could not reset to stock"); + CHECK_FALSE(b.stock_restored); + CHECK(stuck.logged("reset FAILED")); + CHECK(stuck.card.recover_calls == 1); + CHECK(stuck.card.probes == 0); +} + +// The recovery gate. Unless stated, the card loses the device at core +135: +// probe 32 of the run, the only driver reset, between the climb and the +// confirm step. + +TEST_CASE("after a reset the search reconnects, goes to stock, rests and proves the card is back before the next candidate") { + Run run = lost_at_135(); + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(first_events_after_lost(run.card, 5) == Events{"recover", "stock", "rest", "health", "begin"}); + CHECK(run.card.rests == 1); + CHECK(count_events(run.card, "health") == 1); + CHECK(r.driver_resets == 1); + CHECK(run.card.recovers_with_entry_open == 0); + CHECK(run.logged("resting 20 s before the card is loaded again")); + CHECK(run.logged("checking that the card is back: 5 s at stock")); + CHECK(run.count_logged("health: ") == 1); + CHECK(run.logged("health: STABLE")); + check_journal_rules(run); +} + +TEST_CASE("a card that needs a second rest is accepted") { + Run run = lost_at_135(); + run.card.sick_health_probes = 1; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(run.card.rests == 2); + CHECK(count_events(run.card, "health") == 2); + // No candidate between the two health probes. + CHECK(first_events_after_lost(run.card, 7) == Events{"recover", "stock", "rest", "health", "rest", "health", "begin"}); + CHECK(r.driver_resets == 1); + CHECK(run.card.recover_calls == 1); + check_journal_rules(run); +} + +TEST_CASE("a card that never recovers ends the run at stock without opening another entry") { + Run run = lost_at_135(); + run.card.sick_health_probes = 99; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the card did not recover after a driver reset"); + CHECK(run.card.rests == 3); + CHECK(count_events(run.card, "health") == 3); + // Three rests and probes, no entry after the lost candidate's, and one + // reset to stock on the way out without another reconnect. + CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "health", "rest", "health", "rest", "health", "stock"}); + CHECK(r.stock_restored); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + CHECK(run.card.power == 100); + CHECK(r.driver_resets == 1); + // The connections were good on the way out: no reconnect, and no line that says one was made. + CHECK_FALSE(run.logged("reconnecting to the driver to restore stock")); + check_journal_rules(run); +} + +TEST_CASE("a driver reset during the health probe ends the run at once") { + Run run = lost_at_135(); + run.card.health_verdict = Verdict::DeviceLost; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the card was not usable when checked after a driver reset (DEVICE LOST)"); + CHECK(r.driver_resets == 2); + CHECK(count_events(run.card, "health") == 1); + CHECK(run.card.rests == 1); + // After the health probe: no entry, no rest, no second health probe; one + // reconnect and one reset to stock on the way out. + CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "health", "recover", "stock"}); + CHECK(run.card.recover_calls == 2); + CHECK(r.stock_restored); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + CHECK(run.card.power == 100); + CHECK_FALSE(run.card.stale); + CHECK(run.logged("health: DEVICE LOST")); + // The reconnect on the way out can take half a minute: the log says so first. + CHECK(run.count_logged("reconnecting to the driver to restore stock") == 1); + check_journal_rules(run); +} + +TEST_CASE("the stall floor of every probe is a quarter of the baseline score, except the baseline and the health probe") { + Run run = lost_at_135(); + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + REQUIRE(r.baseline.score > 0); + const double floor = kStalledScore * r.baseline.score; + const auto& probes = run.card.probe_floors; + REQUIRE(probes.size() == static_cast(run.card.probes)); + // The baseline has nothing to be measured against yet. + CHECK(probes.front().first == 30); + CHECK(probes.front().second == 0); + int power = 0, clock = 0, confirm = 0, soak = 0, health = 0; + for (std::size_t i = 1; i < probes.size(); ++i) { + const double seconds = probes[i].first, stall_below = probes[i].second; + CAPTURE(i); + CAPTURE(seconds); + if (seconds == 5) { + // The health probe is judged on its whole run, after it ends. + ++health; + CHECK(stall_below == 0); + continue; + } + if (seconds == 20) ++power; + else if (seconds == 3) ++clock; + else if (seconds == 30) ++confirm; + else if (seconds == 300) ++soak; + else FAIL_CHECK("a probe of an unexpected length"); + CHECK(stall_below == floor); + } + // Every kind of probe ran: 4 power steps, 17 memory and 9 core candidates, + // the memory and the core confirm probe, one health probe and the soak. + CHECK(power == 4); + CHECK(clock == 26); + CHECK(confirm == 2); + CHECK(health == 1); + CHECK(soak == 1); +} + +TEST_CASE("a blind health probe ends the run at once") { + Run run = lost_at_135(); + run.card.health_verdict = Verdict::NoTelemetry; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the card was not usable when checked after a driver reset (NO TELEMETRY)"); + CHECK(r.driver_resets == 2); + CHECK(count_events(run.card, "health") == 1); + CHECK(run.card.rests == 1); + CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "health", "recover", "stock"}); + CHECK(r.stock_restored); + check_journal_rules(run); +} + +TEST_CASE("a health probe that computes almost nothing ends the run at once") { + Run run = lost_at_135(); + run.card.health_stalls = true; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the card computed almost nothing after a driver reset"); + CHECK(r.driver_resets == 2); + CHECK(count_events(run.card, "health") == 1); + CHECK(run.card.rests == 1); + CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "health", "recover", "stock"}); + CHECK(run.logged("health: STALLED")); + CHECK(r.stock_restored); + CHECK(run.card.core == 0); + check_journal_rules(run); +} + +TEST_CASE("a health probe that only computes wrong or runs hot is retried, not counted") { + for (const Verdict v : {Verdict::WrongResult, Verdict::TooHot}) { + Run run = lost_at_135(); + run.card.health_verdict = v; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 1); + CHECK(count_events(run.card, "health") == 2); + CHECK(run.card.rests == 2); + CHECK(first_events_after_lost(run.card, 7) == Events{"recover", "stock", "rest", "health", "rest", "health", "begin"}); + CHECK(run.card.recover_calls == 1); + check_journal_rules(run); + } +} + +TEST_CASE("a stock write that fails while recovering ends the run without a rest or a health probe") { + Run run = lost_at_135(); + run.card.stock_write_fails_after_recover = true; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "could not set the card to stock while recovering"); + CHECK(run.card.rests == 0); + CHECK(count_events(run.card, "health") == 0); + // The failed write pushes nothing; the way out reconnects once and resets. + CHECK(events_after_lost(run.card) == Events{"recover", "recover", "stock"}); + CHECK(r.driver_resets == 1); + CHECK(r.stock_restored); + check_journal_rules(run); +} + +TEST_CASE("a driver that does not come back is not asked twice") { + Run run = lost_at_135(); + run.card.recover_fails = true; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the driver did not come back after a reset"); + CHECK(run.card.recover_calls == 1); + CHECK(run.card.rests == 0); + CHECK(count_events(run.card, "health") == 0); + CHECK(events_after_lost(run.card) == Events{"recover"}); + CHECK_FALSE(r.stock_restored); + CHECK(r.driver_resets == 1); + check_journal_rules(run); +} + +TEST_CASE("an abort while resting ends as aborted") { + Run run = lost_at_135(); + run.card.abort_during_rest = true; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "aborted"); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + CHECK(run.card.power == 100); + CHECK(count_events(run.card, "health") == 0); + CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "stock"}); + CHECK(r.stock_restored); + check_journal_rules(run); +} + +namespace { +Run lost_at_135_with_prepare() { + Run run = lost_at_135(); + run.card.with_prepare = true; + return run; +} +} + +TEST_CASE("the load is prepared after the rest and before the health probe") { + Run run = lost_at_135_with_prepare(); + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(first_events_after_lost(run.card, 6) == Events{"recover", "stock", "rest", "prepare", "health", "begin"}); + CHECK(run.card.prepares == 1); + CHECK(run.card.prepares_with_entry_open == 0); + CHECK(r.driver_resets == 1); + CHECK(run.logged("preparing the stress load")); + check_journal_rules(run); +} + +TEST_CASE("the load is prepared at no other time") { + Run run; + run.card.with_recover = true; + run.card.with_prepare = true; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(run.card.prepares == 0); +} + +TEST_CASE("a slow health probe does not rebuild the load") { + Run run = lost_at_135_with_prepare(); + run.card.sick_health_probes = 1; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(first_events_after_lost(run.card, 8) == + Events{"recover", "stock", "rest", "prepare", "health", "rest", "health", "begin"}); + CHECK(run.card.prepares == 1); +} + +TEST_CASE("a preparation that fails once costs a rest, not a probe") { + Run run = lost_at_135_with_prepare(); + run.card.prepare_fails = 1; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(first_events_after_lost(run.card, 8) == + Events{"recover", "stock", "rest", "prepare", "rest", "prepare", "health", "begin"}); + CHECK(count_events(run.card, "health") == 1); + CHECK(r.driver_resets == 1); + CHECK(run.logged("the stress load is not ready yet")); + check_journal_rules(run); +} + +TEST_CASE("a load that cannot be prepared ends the run without a health probe") { + Run run = lost_at_135_with_prepare(); + run.card.prepare_fails = 99; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the card did not recover after a driver reset"); + CHECK(events_after_lost(run.card) == + Events{"recover", "stock", "rest", "prepare", "rest", "prepare", "rest", "prepare", "stock"}); + CHECK_FALSE(run.logged("checking that the card is back")); + CHECK(r.stock_restored); + CHECK(run.card.core == 0); + CHECK(r.driver_resets == 1); + Journal reread(run.card.journal, [](const std::string&) { return true; }); + CHECK(reread.freezes().empty()); +} + +TEST_CASE("a stop request during the preparation ends the run as aborted") { + Run run = lost_at_135_with_prepare(); + run.card.abort_after_prepare = true; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "aborted"); + CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "prepare", "stock"}); + CHECK(r.stock_restored); + CHECK(run.card.core == 0); +} + +TEST_CASE("the stale-start rest does not prepare the load") { + Run run; + run.card.with_recover = true; + run.card.with_prepare = true; + run.card.stale = true; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(run.card.prepares == 0); + REQUIRE(run.card.events.size() >= 3); + CHECK(Events(run.card.events.begin(), run.card.events.begin() + 3) == Events{"recover", "stock", "rest"}); +} + +TEST_CASE("without a preparation callback the gate is unchanged") { + Run run = lost_at_135(); + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(first_events_after_lost(run.card, 4) == Events{"recover", "stock", "rest", "health"}); + CHECK(run.card.prepares == 0); +} + +TEST_CASE("without a rest callback failed preparations follow each other") { + Run run = lost_at_135_with_prepare(); + run.no_rest = true; + run.card.prepare_fails = 99; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the card did not recover after a driver reset"); + CHECK(run.card.prepares == 3); + CHECK(run.card.rests == 0); +} + +TEST_CASE("the health probe is not journaled") { + Run run = lost_at_135(); + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(count_events(run.card, "health") == 1); + // Every clock candidate logs "core +C / mem +M: ...", every soak attempt + // "soak: 300 s at ...": one journal entry each, and none for the health probe. + const int entries = run.count_logged(" / mem +") + run.count_logged("soak: 300 s at"); + CHECK(entries > 0); + CHECK(run.card.count("\"begin\"") == entries); + CHECK(run.card.count("\"complete\"") == entries); +} + +TEST_CASE("without a rest callback the gate still proves the card is back") { + Run run = lost_at_135(); + run.no_rest = true; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(count_events(run.card, "health") == 1); + CHECK(run.card.rests == 0); + CHECK(first_events_after_lost(run.card, 4) == Events{"recover", "stock", "health", "begin"}); + CHECK_FALSE(run.logged("resting")); // nothing waited, so the log does not say it did + check_journal_rules(run); +} + +TEST_CASE("a connection that is stale at the start gets a rest before the baseline") { + Run run; + run.card.with_recover = true; + run.card.stale = true; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + // The first reset fails on the stale connection and pushes nothing. + REQUIRE(run.card.events.size() >= 4); + CHECK(Events(run.card.events.begin(), run.card.events.begin() + 4) == Events{"recover", "stock", "rest", "begin"}); + CHECK(run.card.rests == 1); + CHECK(run.logged("resting 20 s before the card is loaded again")); + CHECK(count_events(run.card, "health") == 0); + CHECK(r.driver_resets == 0); + CHECK(run.card.probes == 36); // the run of a card with no reset + check_journal_rules(run); +} + +TEST_CASE("an abort while resting at the start ends before the baseline") { + Run run; + run.card.with_recover = true; + run.card.stale = true; + run.card.abort_during_rest = true; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "aborted"); + CHECK(run.card.probes == 0); + CHECK(r.stock_restored); + CHECK(run.card.recover_calls == 1); +} + +TEST_CASE("without a reconnect nothing changes") { + Run run; + run.card.lost_core_from = 135; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason.find("failed") != std::string::npos); + CHECK_FALSE(r.stock_restored); + CHECK(run.card.rests == 0); + CHECK(count_events(run.card, "health") == 0); + CHECK(count_events(run.card, "recover") == 0); + Journal reread(run.card.journal, [](const std::string&) { return true; }); + CHECK(reread.freezes().empty()); +} + +TEST_CASE("a stop request during the reconnect after a failed write is reported as aborted") { + Run run = recovering(); + run.card.abort_in_recover = true; + run.card.fail_core_set_at = 150; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "aborted"); + CHECK(r.stock_restored); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + CHECK(run.card.power == 100); + CHECK(run.card.count("SET FAILED") == 1); + // The reconnect of the gate, stock, and then nothing but the reset on the way out. + CHECK(events_after_lost(run.card) == Events{"recover", "stock", "stock"}); + check_journal_rules(run); +} + +TEST_CASE("a stop request that arrived during the reconnect ends the run before the rest") { + Run run = recovering(); + run.card.lost_core_from = 135; + run.card.abort_in_recover = true; // the reconnect itself succeeds + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "aborted"); + CHECK(run.card.rests == 0); + CHECK_FALSE(run.logged("resting")); + CHECK(count_events(run.card, "health") == 0); + CHECK(events_after_lost(run.card) == Events{"recover", "stock", "stock"}); + CHECK(r.stock_restored); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + check_journal_rules(run); +} + +TEST_CASE("a health probe that is stopped ends the run as aborted") { + Run run = recovering(); + run.card.lost_core_from = 135; + run.card.abort_in_probe_s = 5; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "aborted"); + CHECK(r.driver_resets == 1); + CHECK(count_events(run.card, "health") == 1); + CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "health", "stock"}); + CHECK(r.stock_restored); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + CHECK(run.card.power == 100); + check_journal_rules(run); +} + +TEST_CASE("a reconnect that fails on the way out after a lost health probe is reported, not claimed as stock") { + Run run = recovering(); + run.card.lost_core_from = 135; + run.card.health_verdict = Verdict::DeviceLost; + run.card.recover_fails_from_call = 2; // the reconnect of the gate works, the one on the way out does not + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the card was not usable when checked after a driver reset (DEVICE LOST)"); + CHECK(r.driver_resets == 2); + CHECK_FALSE(r.stock_restored); + CHECK(run.card.recover_calls == 2); + CHECK(run.logged("reset FAILED")); + CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "health", "recover"}); + check_journal_rules(run); +} + +// The core offsets of a run's 30 s confirm probes, in the order they ran. +static std::vector core_confirms(const FakeCard& card) { + std::vector out; + for (const std::string& p : card.probe_order) { + if (p.rfind("30:", 0) != 0) continue; + const auto core_at = p.find(':', 3) + 1; + const int core = std::stoi(p.substr(core_at, p.find(':', core_at) - core_at)); + if (core != 0) out.push_back(core); // core 0 is the memory's confirm probe + } + return out; +} + +TEST_CASE("a confirm probe that is too hot before the clock was tested confirms nothing") { + // A 3080 Ti, 0.4.5: the driver reset at core +300, the health probe at + // stock power left the card at 76 C, and `confirm core +225: TOO HOT` + // ended within a second. That counted as holding, and +225 was confirmed + // by a probe that had tested nothing. + Run run = lost_at_135(); + run.card.hot_after_health_s = 0.6; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 1); + // The reset came at +135, +120 was the last value that passed, and the + // confirm starts four steps below it. That probe ends too hot at once and + // does not count; the card cools, and one step lower runs its 30 s. + CHECK(core_confirms(run.card) == std::vector{60, 45}); + CHECK(r.core_confirmed == 45); + CHECK(r.core_mhz <= 45); + CHECK(run.logged("confirm core +60 / mem +550: TOO HOT")); + CHECK(run.logged("too hot after 0.6 s, before the clock was tested: not confirmed")); + CHECK(run.card.rests == 2); // after the reset, and to cool down before the next try + check_journal_rules(run); +} + +TEST_CASE("a confirm probe that got too hot after the clock was tested still keeps the clock edge") { + Run run = lost_at_135(); + run.card.hot_after_health_s = kConfirmMinS; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(core_confirms(run.card) == std::vector{60}); + CHECK(r.core_confirmed == 60); + CHECK(run.card.rests == 1); + CHECK_FALSE(run.logged("not confirmed")); +} + +TEST_CASE("a stop request while the card cools after such a confirm probe ends the run at stock") { + Run run = lost_at_135(); + run.card.hot_after_health_s = 0.6; + run.card.abort_on_rest = 2; // the second rest is the cool-down + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "aborted"); + CHECK(r.stock_restored); + CHECK(core_confirms(run.card) == std::vector{60}); +} diff --git a/tests/test_search_reset_budget.cpp b/tests/test_search_reset_budget.cpp new file mode 100644 index 0000000..4d93fe4 --- /dev/null +++ b/tests/test_search_reset_budget.cpp @@ -0,0 +1,893 @@ +#include "doctest/doctest.h" +#include "search_fixture.hpp" + +// The reset budget. A reset event is a probe that ends DEVICE LOST, STALLED or +// NO TELEMETRY, a write that fails, or a bandwidth measurement that fails above +// memory +0. The first one ends exploring; the second one ends the run. + +TEST_CASE("a reset in the core climb ends the climb at the last value that passed") { + for (const bool stall : {false, true}) { + CAPTURE(stall); + Run run = recovering(); + (stall ? run.card.stall_core_from : run.card.lost_core_from) = 135; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 1); + CHECK(r.core_max_stable == 120); + CHECK(run.card.max_core_seen == 135); // nothing above the candidate that reset the driver was tried + // The climb stops at +135; the first entry after it is the confirm + // probe, four steps below +120. + CHECK(core_begins(run.card) == climb(15, 135, 15, {60})); + CHECK(r.core_confirmed == 60); + CHECK(r.core_mhz == 30); + CHECK(run.logged(stall ? "core +135 / mem +550: STALLED" : "core +135 / mem +550: DEVICE LOST")); + CHECK(run.logged("core: the driver reset at +135; using +120, the last value that passed")); + CHECK(run.logged("confirm core +60 / mem +550: STABLE")); + CHECK(first_events_after_lost(run.card, 5) == Events{"recover", "stock", "rest", "health", "begin"}); + CHECK(run.card.recover_calls == 1); + CHECK_FALSE(run.card.stale); + CHECK(run.card.core == r.core_mhz); // the result is really applied + CHECK(run.card.mem == 550); + CHECK(run.card.power == 115); + check_reconnected_up_front(run); + check_journal_rules(run); + } +} + +TEST_CASE("a reset is never stored as a ceiling") { + Run first = recovering(); + first.card.lost_core_from = 135; + const auto a = first.go(Preset::BestOfMyGpu); + REQUIRE(a.ok); + CHECK(a.core_max_stable == 120); + Journal reread(first.card.journal, [](const std::string&) { return true; }); + CHECK(reread.freezes().empty()); + CHECK(reread.ceilings().core_mhz == INT_MAX); + CHECK(reread.ceilings().mem_mhz == INT_MAX); + + // The next run on the same journal, with nothing resetting the driver. + Run second = recovering(); + second.card.journal = first.card.journal; + const auto b = second.go(Preset::BestOfMyGpu); + REQUIRE(b.ok); + CHECK(b.core_max_stable == 150); + CHECK(b.driver_resets == 0); +} + +TEST_CASE("a second reset in a confirm probe ends the run and starts nothing") { + Run run = recovering(); + run.card.lost_core_from = 135; + run.card.lost_on_first_core_confirm = true; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the driver reset twice"); + CHECK(r.driver_resets == 2); + CHECK(run.logged("confirm core +60 / mem +550: DEVICE LOST")); + CHECK(core_begins(run.card) == climb(15, 135, 15, {60})); + // After the lost confirm probe: one reconnect and one reset to stock on the way out. + check_nothing_started(events_after_last_begin(run.card)); + CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); + CHECK(run.card.rests == 1); + CHECK(count_events(run.card, "health") == 1); + // 23 probes up to the memory confirm, core +15 .. +135 (24-32), the health + // probe (33), the lost confirm probe (34), and none after it. + CHECK(run.card.probes == 34); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + CHECK(run.card.power == 100); + CHECK(r.stock_restored); + REQUIRE_FALSE(run.card.journal.empty()); + CHECK(run.card.journal.back().find("DEVICE LOST") != std::string::npos); + check_journal_rules(run); +} + +TEST_CASE("a second reset in the memory confirm probe ends the run and starts nothing") { + Run run = recovering(); + run.card.lost_mem_from = 300; // the first event, in the memory climb + run.card.lost_on_first_mem_confirm = true; // the second, in the confirm probe at +50 + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the driver reset twice"); + CHECK(r.driver_resets == 2); + CHECK(run.logged("core +0 / mem +300: DEVICE LOST")); + CHECK(run.logged("confirm core +0 / mem +50: DEVICE LOST")); + // The climb to +300, then the one confirm entry four steps below +250. + CHECK(mem_begins(run.card) == climb(50, 300, 50, {50})); + CHECK(core_begins(run.card).empty()); + check_nothing_started(events_after_last_begin(run.card)); + CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); + CHECK(run.card.rests == 1); + CHECK(count_events(run.card, "health") == 1); + // The baseline (1), four power probes (2-5), memory +50 .. +300 (6-11), the + // health probe (12), the lost confirm probe (13), and none after it. + CHECK(run.card.probes == 13); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + CHECK(run.card.power == 100); + CHECK(r.stock_restored); + CHECK_FALSE(run.card.stale); + REQUIRE_FALSE(run.card.journal.empty()); + CHECK(run.card.journal.back().find("DEVICE LOST") != std::string::npos); + check_journal_rules(run); +} + +TEST_CASE("a first reset in the core confirm probe backs off four steps") { + Run run = recovering(); + run.card.lost_on_first_core_confirm = true; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 1); + CHECK(r.core_max_stable == 150); + CHECK(run.logged("confirm core +150 / mem +550: DEVICE LOST")); + CHECK(run.logged("confirm core +90 / mem +550: STABLE")); + // The climb, the lost confirm probe, and the next try four steps down. + CHECK(core_begins(run.card) == climb(15, 165, 15, {150, 90})); + CHECK(r.core_confirmed == 90); + CHECK(r.core_mhz == 60); + // The gate lies between the two confirm entries. + CHECK(first_events_after_lost(run.card, 5) == Events{"recover", "stock", "rest", "health", "begin"}); + CHECK(run.card.core == 60); + CHECK(run.card.mem == 550); // the full state was rewritten after the reset + CHECK(run.card.recover_calls == 1); + CHECK_FALSE(run.card.stale); + check_reconnected_up_front(run); + check_journal_rules(run); +} + +TEST_CASE("a second reset in the soak ends the run and starts nothing") { + Run run = recovering(); + run.card.lost_core_from = 135; + run.card.lost_on_first_soak = true; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the driver reset twice"); + CHECK(r.driver_resets == 2); + CHECK(run.logged("soak: 300 s at power 115 %, core +30, mem +550")); + CHECK(run.logged("soak: DEVICE LOST")); + CHECK(soak_entries(run.card) == 1); + REQUIRE_FALSE(run.card.journal.empty()); + CHECK(run.card.journal.back().find("DEVICE LOST") != std::string::npos); + check_nothing_started(events_after_last_begin(run.card)); + CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); + CHECK(run.card.rests == 1); + CHECK(count_events(run.card, "health") == 1); + // 32 probes up to the lost core +135, the health probe (33), the confirm + // probe at +60 (34), the lost soak (35), and none after it. + CHECK(run.card.probes == 35); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + CHECK(run.card.power == 100); + CHECK(r.stock_restored); + check_journal_rules(run); +} + +TEST_CASE("a second reset as a failed soak write ends the run and starts nothing") { + Run run = recovering(); + run.card.lost_core_from = 135; // the first event, in the core climb + run.card.fail_set_on_first_soak = true; // the second: the soak's core write, +30 + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the driver reset twice"); + CHECK(r.driver_resets == 2); + CHECK(run.logged("setting core +30 failed; treated as a driver reset")); + CHECK(soak_entries(run.card) == 1); + REQUIRE_FALSE(run.card.journal.empty()); + CHECK(run.card.journal.back().find("SET FAILED") != std::string::npos); + CHECK(run.card.count("SET FAILED") == 1); + CHECK(run.count_logged("soak: 300 s at") == 0); // the soak never ran + check_nothing_started(events_after_last_begin(run.card)); + CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); + CHECK(run.card.rests == 1); // the one of the first event + CHECK(count_events(run.card, "health") == 1); + // 32 probes up to the lost core +135, the health probe (33), the confirm + // probe at +60 (34), and none after it: the soak's write failed. + CHECK(run.card.probes == 34); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + CHECK(run.card.power == 100); + CHECK(r.stock_restored); + check_journal_rules(run); +} + +TEST_CASE("a first reset in the soak backs both clocks off four steps") { + Run run = recovering(); + run.card.lost_on_first_soak = true; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 1); + CHECK(run.logged("soak: DEVICE LOST")); + CHECK(run.logged("soak: 300 s at power 115 %, core +45, mem +350")); + CHECK(soak_entries(run.card) == 2); + CHECK(run.card.probes == 38); // the lost soak (probe 36), the health probe at stock, the second soak + CHECK(count_events(run.card, "health") == 1); + CHECK(first_events_after_lost(run.card, 5) == Events{"recover", "stock", "rest", "health", "begin"}); + CHECK(r.soak.seconds == 300); + CHECK(r.core_mhz == 45); + CHECK(r.mem_mhz == 350); + CHECK(run.card.core == 45); + CHECK(run.card.mem == 350); + CHECK(run.card.power == 115); + CHECK(run.card.recover_calls == 1); + CHECK_FALSE(run.card.stale); + check_reconnected_up_front(run); + check_journal_rules(run); +} + +TEST_CASE("a reset during the health probe after a soak reset ends the run") { + Run run = recovering(); + run.card.lost_on_first_soak = true; + run.card.health_verdict = Verdict::DeviceLost; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the card was not usable when checked after a driver reset (DEVICE LOST)"); + CHECK(r.driver_resets == 2); + CHECK(run.card.probes == 37); // the lost soak is probe 36; then only the health probe + CHECK(soak_entries(run.card) == 1); + // The gate, called from the soak, stops the run: no rest, no second health + // probe and no entry after it; one reconnect and one reset on the way out. + CHECK(events_after_lost(run.card) == Events{"recover", "stock", "rest", "health", "recover", "stock"}); + CHECK(r.stock_restored); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + CHECK(run.card.power == 100); + check_journal_rules(run); +} + +TEST_CASE("a reset during the baseline ends the run") { + Run run = recovering(); + run.card.lost_at_probe = 1; // the baseline + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the driver reset at stock clocks (DEVICE LOST)"); + CHECK(r.driver_resets == 1); + CHECK(r.baseline.seconds == 30); + CHECK(run.card.probes == 1); + CHECK(count_events(run.card, "health") == 0); + CHECK(run.card.rests == 0); + CHECK(r.stock_restored); + CHECK_FALSE(run.card.stale); + CHECK(run.card.recover_calls == 1); + CHECK(run.card.journal.empty()); + check_journal_rules(run); +} + +TEST_CASE("without a reconnect a reset during the baseline keeps its old reason") { + Run run; + run.card.lost_at_probe = 1; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "stock is not stable (DEVICE LOST)"); + CHECK(r.driver_resets == 1); // still counted + CHECK(run.card.probes == 1); +} + +TEST_CASE("a reset during the power step ends the run") { + Run run = recovering(); + run.card.lost_at_probe = 2; // the first 20 s probe, at 85 % + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(run.logged("power 85 %: DEVICE LOST")); + CHECK(r.reason == "the driver reset at stock clocks (DEVICE LOST)"); + CHECK(r.driver_resets == 1); + CHECK(run.card.probes == 2); + CHECK(run.card.power == 100); + CHECK(run.card.core == 0); + CHECK(count_events(run.card, "health") == 0); + CHECK(run.card.rests == 0); + CHECK(r.stock_restored); + CHECK_FALSE(run.card.stale); + CHECK(run.card.recover_calls == 1); + CHECK(run.card.journal.empty()); + check_journal_rules(run); +} + +TEST_CASE("one blind probe ends the climb at the last value that passed") { + Run run = recovering(); + run.card.blind_at_core = 135; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 1); + CHECK(r.core_max_stable == 120); + CHECK(run.logged("core +135 / mem +550: NO TELEMETRY")); + // No 3 s core probe after it: nine climb entries and one confirm entry, + // four steps below +120. That 30 s probe is not blind and holds. + CHECK(run.card.max_core_seen == 135); + const auto begins = core_begins(run.card); + REQUIRE(begins.size() == 10); + CHECK(std::vector(begins.begin(), begins.begin() + 9) == climb(15, 135, 15)); + CHECK(begins[9] == 60); + CHECK(r.core_confirmed == 60); + CHECK(run.card.recover_calls == 1); + check_journal_rules(run); +} + +TEST_CASE("a blind probe ends the clock search it is in: the memory search, then the core search") { + Run run = recovering(); + run.card.blind_probes = 2; // the first two 3 s probes are blind + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 2); + // The first blind probe is the first 3 s one, memory +50. It is not a + // measured failure and nothing is bisected below it: the memory stays at + // stock. The core has a search of its own, and its first probe, +15, is + // the second blind one: the core stays at stock too, and no third 3 s + // probe runs. + CHECK(run.logged("core +0 / mem +50: NO TELEMETRY")); + CHECK(run.logged("core +15 / mem +0: NO TELEMETRY")); + CHECK(run.card.blind_probes == 0); + CHECK(mem_begins(run.card) == std::vector{50}); + CHECK(core_begins(run.card) == std::vector{15}); + CHECK(r.mem_max_stable == 0); + CHECK(r.mem_mhz == 0); + CHECK(r.core_mhz == 0); + CHECK(run.card.max_mem_seen == 50); + CHECK(run.card.max_core_seen == 15); + CHECK(run.card.recover_calls == 2); + CHECK_FALSE(run.logged("lost telemetry")); + check_journal_rules(run); +} + +TEST_CASE("a failed candidate write is a reset event and goes through the gate") { + Run run = recovering(); + run.card.fail_core_set_once_at = 135; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 1); + CHECK(run.logged("setting core +135 failed; treated as a driver reset")); + CHECK_FALSE(run.logged("trying once more")); + // The +135 entry is closed as SET FAILED and the value gets no second entry. + CHECK(run.card.count("SET FAILED") == 1); + CHECK(core_begins(run.card) == climb(15, 135, 15, {60})); + CHECK(run.card.max_core_seen == 120); + CHECK(first_events_after_lost(run.card, 5) == Events{"recover", "stock", "rest", "health", "begin"}); + CHECK(r.core_max_stable == 120); + CHECK(r.core_confirmed == 60); + CHECK(run.card.recover_calls == 1); + CHECK(run.card.recovers_with_entry_open == 0); + check_journal_rules(run); +} + +TEST_CASE("a write that keeps failing is never written again and the run completes below it") { + Run run = recovering(); + run.card.fail_core_set_at = 150; // every write of +150 fails + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 1); + CHECK(run.logged("setting core +150 failed; treated as a driver reset")); + CHECK(run.card.count("SET FAILED") == 1); // one entry for the value, and no retry + CHECK(core_begins(run.card) == climb(15, 150, 15, {75})); + CHECK(run.card.max_core_seen == 135); + CHECK(r.core_max_stable == 135); + CHECK(r.core_confirmed == 75); + CHECK(r.core_mhz == 45); + CHECK(run.card.core == 45); + CHECK(run.card.mem == 550); + CHECK(run.card.power == 115); + CHECK(run.card.recover_calls == 1); + check_journal_rules(run); +} + +TEST_CASE("a failed write on the first core candidate leaves the core at stock") { + Run run = recovering(); + run.card.flaky_core_sets = 1; // the first non-zero core write, +15 + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 1); + CHECK(run.card.count("SET FAILED") == 1); + CHECK(core_begins(run.card) == std::vector{15}); // no retry, and nothing to confirm above stock + CHECK(r.core_max_stable == 0); + CHECK(r.core_mhz == 0); + CHECK(run.card.max_core_seen == 0); + CHECK(r.mem_mhz == 550); + CHECK(run.card.mem == 550); + CHECK(run.card.recover_calls == 1); + check_journal_rules(run); +} + +TEST_CASE("a failed write after an earlier reset ends the run") { + Run run = recovering(); + run.card.lost_core_from = 135; + run.card.fail_next_core_set_after_recover = true; // the write of the confirm candidate, +60 + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the driver reset twice"); + CHECK(r.driver_resets == 2); + CHECK(run.logged("setting core +60 failed; treated as a driver reset")); + CHECK(core_begins(run.card) == climb(15, 135, 15, {60})); + REQUIRE_FALSE(run.card.journal.empty()); + CHECK(run.card.journal.back().find("SET FAILED") != std::string::npos); + CHECK(run.card.count("SET FAILED") == 1); + check_nothing_started(events_after_last_begin(run.card)); + CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); + CHECK(r.stock_restored); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + CHECK(run.card.power == 100); + check_journal_rules(run); +} + +TEST_CASE("a failed write in the power step ends the run") { + Run run = recovering(); + run.card.flaky_power_sets = 1; // the write of the first power step, 85 % + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the driver reset at stock clocks (setting power 85 % failed)"); + CHECK(run.logged("setting power 85 % failed; treated as a driver reset")); + CHECK(r.driver_resets == 1); + CHECK(run.card.probes == 1); // the baseline only + CHECK(run.card.power == 100); + CHECK(run.card.recover_calls == 1); // on the way out; no retry of the write + CHECK(count_events(run.card, "health") == 0); + CHECK(run.card.rests == 0); + CHECK(r.stock_restored); + CHECK(run.card.journal.empty()); +} + +TEST_CASE("without a reconnect a failed write in the power step stops the run as before") { + Run run; + run.card.flaky_power_sets = 1; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "setting power 85 % failed"); + CHECK(r.driver_resets == 0); + CHECK(run.card.power == 100); + CHECK(r.stock_restored); +} + +TEST_CASE("a failed write of the soak state is a soak reset") { + Run run = recovering(); + run.card.fail_set_on_first_soak = true; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 1); + CHECK(run.logged("setting core +105 failed; treated as a driver reset")); + CHECK(soak_entries(run.card) == 2); + CHECK(run.card.count("SET FAILED") == 1); + // The first soak entry is the one closed as SET FAILED: the only soak that + // ran is the second, four steps down, after the gate. + CHECK(run.count_logged("soak: 300 s at") == 1); + CHECK(run.logged("soak: 300 s at power 115 %, core +45, mem +350")); + CHECK(first_events_after_lost(run.card, 5) == Events{"recover", "stock", "rest", "health", "begin"}); + CHECK(r.core_mhz == 45); + CHECK(r.mem_mhz == 350); + CHECK(run.card.core == 45); + CHECK(run.card.mem == 350); + CHECK(run.card.recover_calls == 1); + check_journal_rules(run); +} + +TEST_CASE("a failed bandwidth measurement ends the memory scan; the core is still searched") { + Run run = recovering(); + run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; + run.card.bw_fails_once_at_mem = 300; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 1); + // The scan samples +0 .. +300. +250 was the highest step that passed, so + // the second pass would begin at +50; after a reset nothing is read, and + // confirming starts four steps below that, which is stock. The +0 sample + // names no clock in the journal. + CHECK(mem_begins(run.card) == climb(50, 300, 50)); + CHECK(clockless_begins(run.card) == 1); + // +0, +50 .. +300, core +15 .. +165, the core confirm probe, the soak + CHECK(run.card.count("\"begin\"") == 20); + CHECK(run.logged("core +0 / mem +0: STABLE")); + CHECK(run.logged("mem +0: bandwidth 500.0 GB/s")); + CHECK(run.card.max_mem_seen == 300); + CHECK(run.card.bw_measurements == 7); + CHECK(run.card.bw_again_measurements == 0); + CHECK(r.mem_max_stable == 50); + CHECK(r.mem_confirmed == 0); + CHECK(r.mem_mhz == 0); + CHECK(run.card.max_core_seen == 165); + CHECK(r.core_mhz == 105); + CHECK_FALSE(run.logged("core: not searched")); + CHECK(run.card.recover_calls == 1); + CHECK(run.card.count("SET FAILED") == 0); // reconnected before the next entry, not after a failed write in it + CHECK(run.logged("the driver was reset; reconnecting")); + CHECK_FALSE(run.card.stale); + check_journal_rules(run); +} + +TEST_CASE("bandwidth readings that do not settle end the memory scan and are no reset") { + Run run = recovering(); + run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; + run.card.bw_unsettled_at_mem = 300; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 0); + CHECK(run.card.recover_calls == 0); + CHECK_FALSE(run.logged("treated as a driver reset")); + CHECK(run.logged("mem +300: the bandwidth readings did not settle; the memory scan ends here")); + // The scan ends at +300; +250 was the highest step that passed, so the + // second pass begins at +50, and the confirm probe runs at what it kept, + // not four steps below that. + CHECK(run.card.max_mem_seen == 300); + CHECK(run.card.max_again_mem == 50); + CHECK(r.mem_max_stable == 50); + CHECK(r.mem_confirmed == 50); + // The core is searched as in a run without any event. + CHECK(run.card.max_core_seen > 0); + CHECK(r.core_mhz > 0); + CHECK_FALSE(run.logged("core: not searched")); + check_journal_rules(run); +} + +TEST_CASE("a reset in the memory scan ends that search only; the core is still searched") { + Run run = recovering(); + run.card.lost_mem_from = 300; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 1); + CHECK(run.logged("core +0 / mem +300: DEVICE LOST")); + CHECK(run.logged("mem: the driver reset at +300; using +250, the last value that passed")); + // The climb ends at +300; the confirm probe starts four steps below +250. + CHECK(mem_begins(run.card) == climb(50, 300, 50, {50})); + CHECK(r.mem_max_stable == 250); + CHECK(r.mem_confirmed == 50); + CHECK(r.mem_mhz == 0); + // Each clock has its own search: the core climbs to its edge as in a run + // without a reset (+165 is the first value that fails), and is confirmed + // at the edge, not four steps below it. + CHECK_FALSE(run.logged("core: not searched")); + // The last core entry is the soak's: with memory at +0 it names only the core. + CHECK(core_begins(run.card) == climb(15, 165, 15, {150, 105})); + CHECK(run.card.max_core_seen == 165); + CHECK(r.core_max_stable == 150); + CHECK(r.core_confirmed == 150); + CHECK(r.core_mhz == 105); + CHECK(run.card.mem == 0); + CHECK(run.card.core == 105); + CHECK(run.card.power == 115); // the full state was rewritten after the reset + CHECK(run.card.recover_calls == 1); + CHECK_FALSE(run.card.stale); + check_reconnected_up_front(run); + check_journal_rules(run); +} + +TEST_CASE("no bandwidth is measured after a reset") { + Run run = recovering(); + run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; + run.card.lost_mem_from = 300; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(run.logged("core +0 / mem +300: DEVICE LOST")); + CHECK(run.card.bw_measurements == 6); // +0 .. +250, and none after the lost candidate + CHECK(run.card.max_mem_seen == 300); +} + +TEST_CASE("a measurement that is unavailable from the start leaves the memory at stock") { + // A 3080 Ti, 0.4.3: the measurement failed at +0, the search climbed on + // stability alone to the first wrong result at +2300, and the machine froze + // in the confirm probes just below it. The bandwidth peak is what keeps a + // memory search away from there; without it nothing is searched. + Run run = recovering(); + run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; + run.card.bw_fails_at_zero = true; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 0); + CHECK(run.card.bw_measurements == 1); + // The +0 sample ran and is the first entry of the run, without a clock. + // No memory offset was ever written. + CHECK(mem_begins(run.card).empty()); + CHECK(run.card.max_mem_seen == 0); + CHECK(clockless_begins(run.card) == 1); + REQUIRE_FALSE(run.card.journal.empty()); + CHECK(run.card.journal.front().find("\"mem\":") == std::string::npos); + CHECK(run.card.journal.front().find("\"core\":") == std::string::npos); + CHECK(run.logged("core +0 / mem +0: STABLE")); + CHECK(r.mem_max_stable == 0); + CHECK(r.mem_confirmed == 0); + CHECK(r.mem_mhz == 0); + CHECK(r.core_max_stable == 150); // the core is still searched + CHECK(r.core_mhz > 0); + CHECK(run.card.rests == 0); + CHECK(count_events(run.card, "health") == 0); + CHECK(run.card.recover_calls == 0); + CHECK(run.logged("the bandwidth measurement does not work here; the memory is not overclocked")); + CHECK(run.logged("mem: left at stock")); + CHECK_FALSE(run.logged("searching memory by stability only")); + CHECK_FALSE(run.logged("mem: highest stable")); + CHECK_FALSE(run.logged("bandwidth peak")); + CHECK_FALSE(run.logged("the driver reset during this run")); + check_journal_rules(run); +} + +TEST_CASE("a measurement that fails at +0 because the device was lost is caught by the next write") { + Run run = recovering(); + run.card.bw_curve = [](int m) { return 500 + m * 0.1; }; + run.card.bw_loses_device = true; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 1); + CHECK(run.logged("the bandwidth measurement does not work here; the memory is not overclocked")); + CHECK(run.card.bw_measurements == 1); + // Journal: +0 opened (without a clock) and closed. No memory offset is + // tried; the write of stock that follows the failed measurement is the + // next write, and it is what finds the reset: in the memory search, so the + // core keeps its own. After it: the core search (+15 .. +165, the confirm + // probe) and the soak. + CHECK(mem_begins(run.card).empty()); + CHECK(clockless_begins(run.card) == 1); + CHECK(run.card.count("\"begin\"") == 14); + CHECK(run.logged("core +0 / mem +0: STABLE")); + REQUIRE_FALSE(run.card.journal.empty()); + CHECK(run.card.journal[0].find("\"begin\"") != std::string::npos); + CHECK(run.card.journal[0].find("\"mem\":") == std::string::npos); + CHECK(run.card.count("SET FAILED") == 0); + CHECK(run.logged("failed; treated as a driver reset")); + CHECK(first_events_after_lost(run.card, 5) == Events{"recover", "stock", "rest", "health", "begin"}); + CHECK(r.mem_mhz == 0); + CHECK(run.card.max_core_seen == 165); + CHECK_FALSE(run.logged("core: not searched")); + CHECK(r.core_mhz == 105); + CHECK(run.card.core == r.core_mhz); + CHECK(run.card.recover_calls == 1); + check_journal_rules(run); +} + +TEST_CASE("a reset verdict on the +0 memory sample ends the run") { + Run run = recovering(); + run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; + run.card.lost_mem_from = 0; // every 3 s probe, the +0 sample first + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the driver reset at stock clocks (DEVICE LOST)"); + CHECK(r.driver_resets == 1); + // The +0 sample is the only entry of the run, and it names no clock. + CHECK(run.card.count("\"begin\"") == 1); + CHECK(clockless_begins(run.card) == 1); + CHECK(mem_begins(run.card).empty()); + CHECK(run.logged("core +0 / mem +0: DEVICE LOST")); + CHECK(run.card.max_mem_seen == 0); + CHECK(run.card.bw_measurements == 0); + CHECK(run.card.rests == 0); + CHECK(count_events(run.card, "health") == 0); + CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); + CHECK(r.stock_restored); + check_journal_rules(run); +} + +TEST_CASE("a reset in the memory search and one in the core climb: both clocks back off, the run goes on") { + Run run = recovering(); + run.card.lost_mem_from = 300; + run.card.lost_core_from = 135; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 2); + CHECK(run.card.recover_calls == 2); + CHECK(run.logged("mem: the driver reset at +300; using +250, the last value that passed")); + CHECK(run.logged("core: the driver reset at +135; using +120, the last value that passed")); + // The core climb ends at +135 and its confirm probe starts four steps + // below +120; nothing above the lost value is tried. The last core entry + // is the soak's, at +30. + CHECK(core_begins(run.card) == climb(15, 135, 15, {60, 30})); + CHECK(run.card.max_core_seen == 135); + CHECK(r.core_max_stable == 120); + CHECK(r.core_confirmed == 60); + CHECK(r.mem_confirmed == 50); + CHECK_FALSE(run.card.stale); + check_journal_rules(run); +} + +TEST_CASE("a third reset ends the run and starts nothing") { + Run run = recovering(); + run.card.lost_mem_from = 300; // the first event, in the memory climb + run.card.lost_core_from = 135; // the second, in the core climb + run.card.lost_on_first_soak = true; // the third + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE_FALSE(r.ok); + CHECK(r.reason == "the driver reset three times"); + CHECK(r.driver_resets == 3); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + check_journal_rules(run); +} + +TEST_CASE("a first reset in the memory confirm probe: the core is still searched") { + Run run = recovering(); + run.card.lost_on_first_mem_confirm = true; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 1); + CHECK(run.logged("confirm core +0 / mem +800: DEVICE LOST")); + // The next confirm try is four steps down. The core then has a search of + // its own, with memory +400 applied, and the soak names both clocks. + CHECK(mem_begins(run.card) == climb(50, 850, 50, {800, 600})); + CHECK(core_begins(run.card) == climb(15, 165, 15, {150})); + CHECK(r.mem_max_stable == 800); + CHECK(r.mem_confirmed == 600); + CHECK(r.mem_mhz == 400); + CHECK(run.card.mem == 400); + CHECK(run.card.max_core_seen == 165); + CHECK(r.core_mhz == 105); + CHECK_FALSE(run.logged("core: not searched")); + CHECK(run.logged("soak: 300 s at power 115 %, core +105, mem +400")); + CHECK(run.card.recover_calls == 1); + check_journal_rules(run); +} + +TEST_CASE("a soak entry carries only the clocks that are above stock") { + // A card whose core holds nothing above stock: the soak runs at core +0 / + // memory +400. + Run run = recovering(); + run.card.lost_on_first_mem_confirm = true; + run.card.core_edge = 0; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.core_mhz == 0); + CHECK(r.mem_mhz == 400); + CHECK(run.logged("soak: 300 s at power 115 %, core +0, mem +400")); + // The soak's entry is the last one of the run: its `begin` and its `complete`. + REQUIRE(run.card.journal.size() >= 2); + const std::string& soak_begin = run.card.journal[run.card.journal.size() - 2]; + REQUIRE(soak_begin.find("\"begin\"") != std::string::npos); + CHECK(soak_begin.find("\"mem\":400") != std::string::npos); + CHECK(soak_begin.find("\"core\":") == std::string::npos); + // A machine that froze during that soak leaves the entry open. That must + // not become a core ceiling of 0, which would keep every later run from + // searching the core at all. + REQUIRE(run.card.journal.back().find("\"complete\"") != std::string::npos); + const std::vector frozen(run.card.journal.begin(), run.card.journal.end() - 1); + Journal reread(frozen, [](const std::string&) { return true; }); + CHECK(reread.freezes().size() == 1); + CHECK(reread.ceilings().core_mhz == INT_MAX); + CHECK(reread.ceilings().mem_mhz == 400); +} + +TEST_CASE("the +0 sample of the bandwidth scan journals no clock") { + Run run; + run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.mem_max_stable == 600); // +800 passed; the second pass began at +600 + // The first entry of the run is the +0 sample: it ran and was measured, + // and its `begin` line names neither clock. + CHECK(run.logged("core +0 / mem +0: STABLE")); + CHECK(run.logged("mem +0: bandwidth 500.0 GB/s")); + REQUIRE(run.card.journal.size() >= 3); + const std::string& sample_begin = run.card.journal[0]; + REQUIRE(sample_begin.find("\"begin\"") != std::string::npos); + CHECK(sample_begin.find("\"mem\":") == std::string::npos); + CHECK(sample_begin.find("\"core\":") == std::string::npos); + REQUIRE(run.card.journal[1].find("\"complete\"") != std::string::npos); + // The entry after it is the first one above stock, and it names its clock. + CHECK(run.card.journal[2].find("\"mem\":50") != std::string::npos); + CHECK(clockless_begins(run.card) == 1); // the soak runs above stock + REQUIRE_FALSE(mem_begins(run.card).empty()); + CHECK(mem_begins(run.card).front() == 50); + + // A machine that froze during that sample leaves its entry open. That is + // a freeze, and it must not become a ceiling of 0 for either clock. + const std::vector frozen{sample_begin}; + Journal reread(frozen, [](const std::string&) { return true; }); + CHECK(reread.freezes().size() == 1); + CHECK(reread.ceilings().mem_mhz == INT_MAX); + CHECK(reread.ceilings().core_mhz == INT_MAX); + + // The next run on that journal searches memory as far as the first did. + Run next; + next.card.bw_curve = run.card.bw_curve; + next.card.journal = frozen; + const auto b = next.go(Preset::BestOfMyGpu); + REQUIRE(b.ok); + CHECK(b.mem_max_stable == 600); + CHECK(b.core_max_stable == 150); +} + +TEST_CASE("a soak reset at stock clocks ends the run") { + Run run = recovering(); + run.card.lost_on_first_soak = true; + const auto r = run.go(Preset::CoolAndEfficient); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the driver reset at stock clocks (DEVICE LOST)"); + CHECK(r.driver_resets == 1); + CHECK(run.card.count("\"begin\"") == 1); // exactly one soak entry + CHECK(run.count_logged("soak: 300 s at") == 1); + CHECK(run.card.rests == 0); + CHECK(count_events(run.card, "health") == 0); + CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); + CHECK(r.stock_restored); + CHECK(run.card.power == 100); + check_journal_rules(run); +} + +TEST_CASE("a failed soak write at stock clocks ends the run") { + // The cool preset tunes neither clock: its soak runs at core +0 / memory + // +0, and its entry is the only one of the run. + Run clean = recovering(); + REQUIRE(clean.go(Preset::CoolAndEfficient).ok); + + Run run = recovering(); + run.card.fail_power_set_in_entry = true; // the soak's power write + const auto r = run.go(Preset::CoolAndEfficient); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the driver reset at stock clocks (setting power 85 % failed)"); + CHECK(run.logged("setting power 85 % failed; treated as a driver reset")); + CHECK(r.driver_resets == 1); + CHECK_FALSE(run.card.fail_power_set_in_entry); // the knob fired + CHECK(run.card.count("\"begin\"") == 1); // exactly one soak entry + REQUIRE(run.card.journal.size() == 2); + CHECK(run.card.journal.back().find("SET FAILED") != std::string::npos); + CHECK(run.count_logged("soak: 300 s at") == 0); // the soak never ran + CHECK(run.card.probes == clean.card.probes - 1); // every probe of a clean run but the soak + CHECK(run.card.rests == 0); + CHECK(count_events(run.card, "health") == 0); + CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); + CHECK(run.card.recover_calls == 1); + CHECK(r.stock_restored); + CHECK(run.card.power == 100); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + check_journal_rules(run); +} + +TEST_CASE("a failed write of the +0 memory sample ends the run") { + Run run = recovering(); + run.card.bw_curve = [](int m) { return 500 + m * 0.3; }; + run.card.fail_power_set_in_entry = true; // the first entry of the run is the +0 sample + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "the driver reset at stock clocks (setting power 115 % failed)"); + CHECK(run.logged("setting power 115 % failed; treated as a driver reset")); + CHECK(r.driver_resets == 1); + CHECK_FALSE(run.card.fail_power_set_in_entry); // the knob fired + // The +0 sample is the only entry of the run, and it names no clock. + CHECK(run.card.count("\"begin\"") == 1); + CHECK(clockless_begins(run.card) == 1); + CHECK(mem_begins(run.card).empty()); + CHECK(run.card.max_mem_seen == 0); + REQUIRE(run.card.journal.size() == 2); + CHECK(run.card.journal.back().find("SET FAILED") != std::string::npos); + CHECK(run.card.probes == 5); // the baseline and four power probes; the sample never ran + CHECK(run.card.bw_measurements == 0); + CHECK(run.card.rests == 0); + CHECK(count_events(run.card, "health") == 0); + CHECK(events_after_last_begin(run.card) == Events{"recover", "stock"}); + CHECK(run.card.recover_calls == 1); + CHECK(r.stock_restored); + CHECK(run.card.power == 100); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + check_journal_rules(run); +} + +TEST_CASE("a completed run says that the driver reset") { + Run run = recovering(); + run.card.lost_core_from = 135; + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(run.logged("the driver reset during this run; the search stopped exploring there")); + + Run clean = recovering(); + const auto b = clean.go(Preset::BestOfMyGpu); + REQUIRE(b.ok); + CHECK_FALSE(clean.logged("the driver reset during this run")); +} + +TEST_CASE("without a reconnect a failed write stops the run as before") { + Run run; + run.card.fail_core_set_at = 150; + const auto r = run.go(Preset::BestOfMyGpu); + CHECK_FALSE(r.ok); + CHECK(r.reason == "setting core +150 failed"); + CHECK(r.driver_resets == 0); + CHECK(r.stock_restored); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); // core +150 was written with memory +550 applied + CHECK(run.card.power == 100); + CHECK(run.card.count("SET FAILED") == 1); + CHECK_FALSE(run.logged("treated as a driver reset")); +} + +TEST_CASE("an ordinary failure costs no reset") { + Run run = recovering(); + const auto r = run.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.driver_resets == 0); + CHECK(r.core_max_stable == 150); + CHECK(r.core_confirmed == 150); + CHECK(run.card.recover_calls == 0); + CHECK(run.card.rests == 0); +} diff --git a/tests/test_search_stock.cpp b/tests/test_search_stock.cpp new file mode 100644 index 0000000..be3f429 --- /dev/null +++ b/tests/test_search_stock.cpp @@ -0,0 +1,192 @@ +#include "doctest/doctest.h" +#include "search_fixture.hpp" +#include "core/fan_curve.hpp" + +// The measurement at stock that goes before a run (the fans are the NVIDIA driver's). +static StabilityResult stock_run(Verdict verdict, double seconds, int peak_temp_c) { + StabilityResult s; + s.verdict = verdict; + s.seconds = seconds; + s.peak_temp_c = peak_temp_c; + return s; +} + +TEST_CASE("a card that gets hot at stock with the driver's fans is not refused: the run goes on") { + CHECK(stock_refusal(stock_run(Verdict::Stable, 300, 67)).empty()); + // A 3080 Ti: 85 C under full load with the driver holding its fans at 65 %. Nothing is wrong with it. + CHECK(stock_refusal(stock_run(Verdict::TooHot, 140, kStockTempC + 1)).empty()); + CHECK(stock_refusal(stock_run(Verdict::TooHot, kStockMinS, kStockTempC + 1)).empty()); +} + +TEST_CASE("a card that was hot before the measurement at stock got going is asked to cool down first") { + const std::string why = stock_refusal(stock_run(Verdict::TooHot, 4, 88)); + CHECK(why.find("88 C") != std::string::npos); + CHECK(why.find("cool down") != std::string::npos); + CHECK(why.find("not stable") == std::string::npos); +} + +TEST_CASE("a card that fails at stock for another reason gets no run") { + CHECK(stock_refusal(stock_run(Verdict::WrongResult, 12, 60)) == "the card is not stable at stock (WRONG RESULT)"); + CHECK(stock_refusal(stock_run(Verdict::DeviceLost, 12, 60)) == "the card is not stable at stock (DEVICE LOST)"); + CHECK(stock_refusal(stock_run(Verdict::NoTelemetry, 12, 60)) == "the card is not stable at stock (NO TELEMETRY)"); + CHECK(stock_refusal(stock_run(Verdict::Aborted, 12, 60)) == "aborted"); +} + +TEST_CASE("a run does not start while MSI Afterburner or RivaTuner runs") { + const std::string ab = other_tool_refusal({"explorer.exe", "MSIAfterburner.exe", "Discord.exe"}); + CHECK(ab.find("MSI Afterburner is running") == 0); + CHECK(ab.find("Close it") != std::string::npos); + const std::string rtss = other_tool_refusal({"RTSS.exe"}); + CHECK(rtss.find("RivaTuner Statistics Server (RTSS) is running") == 0); + // Windows does not care about the case of a file name. + CHECK_FALSE(other_tool_refusal({"msiafterburner.EXE"}).empty()); + CHECK_FALSE(other_tool_refusal({"rtss.exe"}).empty()); + // Afterburner starts RivaTuner with it: the one the user knows is named. + CHECK(other_tool_refusal({"RTSS.exe", "MSIAfterburner.exe"}).find("MSI Afterburner") == 0); +} + +TEST_CASE("a run starts when neither runs, whatever else does") { + CHECK(other_tool_refusal({}).empty()); + CHECK(other_tool_refusal({"explorer.exe", "Discord.exe", "GpuAutoOptimizer.exe", "gao.exe"}).empty()); + // Only the programs themselves: not a file that merely carries the name. + CHECK(other_tool_refusal({"RTSSHooksLoader64.exe", "MSIAfterburner.exe.bak", "NotRTSS.exe", "Afterburner"}).empty()); +} + +namespace { + +// A card that is warm before anything is changed: 15 C above the usual one, so +// that the profile's 75 C only holds at a power limit of 65 %. The fake card +// scores by its power limit (1000 it/s from 90 % up), so that costs speed, as +// it did on an RTX 3080 Ti: 4749 it/s after the run, 4937 at stock. +Run warm_card(double stock_score) { + Run run; + run.card.extra_heat = 15; + StabilityResult stock; + stock.verdict = Verdict::Stable; + stock.seconds = 300; + stock.score = stock_score; + stock.peak_temp_c = 85; + run.stock = stock; + return run; +} + +} + +TEST_CASE("a profile that is after speed keeps nothing when the result is slower than stock") { + Run run = warm_card(1000); + const auto r = run.go(Preset::BestOfMyGpu); + CAPTURE(r.reason); + CHECK_FALSE(r.ok); + CHECK(r.reason.find("slower than stock") != std::string::npos); + CHECK(r.reason.find("722 it/s against 1000") != std::string::npos); + CHECK(r.reason.find("power limit went down to 65 %") != std::string::npos); + CHECK(r.reason.find("Undervolt") != std::string::npos); + // Not applied: the card is at stock again. + CHECK(r.stock_restored); + CHECK(run.card.core == 0); + CHECK(run.card.mem == 0); + CHECK(run.card.power == 100); + // The search itself ran to its end: the result was tested before it was judged. + CHECK(run.logged("soak: STABLE")); + check_journal_rules(run); +} + +TEST_CASE("max performance keeps nothing either when the card scored more at stock") { + // Its limit is 83 C, so this card keeps a power limit of 90 %: the fake + // card's full 1000 it/s. The card scored more than that at stock. + Run run = warm_card(1100); + const auto r = run.go(Preset::MaxPerformance); + CAPTURE(r.reason); + CHECK_FALSE(r.ok); + CHECK(r.reason.find("slower than stock: 1000 it/s against 1100") != std::string::npos); + CHECK(r.stock_restored); + CHECK(run.card.core == 0); + CHECK(run.card.power == 100); +} + +TEST_CASE("a profile that is after speed keeps a result that is faster than stock, or as fast") { + Run faster = warm_card(700); + const auto r = faster.go(Preset::BestOfMyGpu); + REQUIRE(r.ok); + CHECK(r.power_pct == 65); + CHECK(faster.card.power == 65); // applied + CHECK(faster.card.core == r.core_mhz); + // Within one percent of stock is the scatter of two five-minute runs, not a slower card. + Run level = warm_card(728); // 722 against 728: 0.8 % less + CHECK(level.go(Preset::BestOfMyGpu).ok); + Run just_slower = warm_card(735); // 1.8 % less + CHECK_FALSE(just_slower.go(Preset::BestOfMyGpu).ok); +} + +TEST_CASE("the profiles that give speed away on purpose keep their result when it is slower than stock") { + // Quiet and Cool & efficient lower the power limit for less noise and + // heat. All in one goes on to an undervolt and a fan tune after this step. + for (const Preset preset : {Preset::Quiet, Preset::CoolAndEfficient, Preset::AllInOne}) { + Run run = warm_card(1000); + if (preset == Preset::CoolAndEfficient) run.card.extra_heat = 5; // its limit is 65 C + const auto r = run.go(preset); + CAPTURE(std::string(preset_name(preset))); + CAPTURE(r.reason); + REQUIRE(r.ok); + CHECK(r.soak.score < 1000); + CHECK(run.card.power == r.power_pct); + } +} + +TEST_CASE("without a measurement at stock there is nothing to be slower than") { + Run run = warm_card(1000); + run.stock.reset(); + CHECK(run.go(Preset::BestOfMyGpu).ok); + Run unscored = warm_card(0); // a measurement that scored nothing says nothing + CHECK(unscored.go(Preset::BestOfMyGpu).ok); +} + +TEST_CASE("the temperature ceiling of a run is the profile's limit, or what the card reached at stock, and never more than 85 C") { + CHECK(temp_ceiling(75, 67) == 75); // the reference card: nothing changes + CHECK(temp_ceiling(75, 75) == 75); + CHECK(temp_ceiling(75, 80) == 80); + CHECK(temp_ceiling(75, 86) == 85); // the tester's RTX 3080 Ti + CHECK(temp_ceiling(83, 86) == 85); + CHECK(temp_ceiling(75, -1) == 75); // no reading + CHECK(temp_ceiling(65, 70) == 70); +} +TEST_CASE("the card's own temperature target is the ceiling's top, where the card reports one") { + // The card throttles itself at its target: a run has no business above it. + CHECK(temp_ceiling(75, 86, 83) == 83); // an RTX 3080 Ti at its default + CHECK(temp_ceiling(75, 86, 77) == 77); // the same card with the limit its owner set + CHECK(temp_ceiling(75, 80, 83) == 80); // what the card reached, when that is less + CHECK(temp_ceiling(75, 67, 84) == 75); // the reference card: nothing changes + // A target above 85 C does not raise the top: the measurement at stock + // stops there, so nothing above it was ever seen on this card. + CHECK(temp_ceiling(75, 86, 88) == 85); + CHECK(temp_ceiling(75, 86, 91) == 85); +} + +TEST_CASE("a card that reports no temperature target keeps 85 C, exactly as before") { + for (const int limit : {65, 75, 80, 83}) + for (int peak = -1; peak <= 95; ++peak) { + CAPTURE(limit); + CAPTURE(peak); + CHECK(temp_ceiling(limit, peak, 0) == temp_ceiling(limit, peak)); + CHECK(temp_ceiling(limit, peak) == std::max(limit, std::min(peak, kStockTempC))); + CHECK(temp_ceiling(limit, peak, -1) == temp_ceiling(limit, peak)); + } +} + +TEST_CASE("a target below the profile's own limit does not lower that limit") { + // The profile's limit is what the profile was asked for; a card that + // throttles below it does so by itself, as it did before. + CHECK(temp_ceiling(75, 86, 70) == 75); + CHECK(temp_ceiling(83, 86, 77) == 83); + CHECK(temp_ceiling(75, 60, 70) == 75); +} + +TEST_CASE("the fan curve moves by what the card's own target leaves, not by a fixed ten degrees") { + const FanCurve normal = fan_preset_curve(FanPreset::Normal); + // Target 83 C: the red line and the last point at 83. + CHECK(shifted_curve(normal, temp_ceiling(75, 86, 83) - 75) == FanCurve{50, {{50, 35}, {68, 45}, {78, 65}, {83, 100}}}); + // Target 77 C, set by the owner: two degrees, and the fans are at 100 % where the card would begin to slow down. + CHECK(shifted_curve(normal, temp_ceiling(75, 86, 77) - 75) == FanCurve{50, {{50, 35}, {62, 45}, {72, 65}, {77, 100}}}); + // No target reported: ten degrees, as in the step before this one. + CHECK(shifted_curve(normal, temp_ceiling(75, 86) - 75) == FanCurve{50, {{50, 35}, {70, 45}, {80, 65}, {85, 100}}}); +} From 4ae38e5d27273e5dfa182dd1b00c1e6e4b747db7 Mon Sep 17 00:00:00 2001 From: Bejeweld Date: Sun, 18 Oct 2026 11:19:15 +0200 Subject: [PATCH 02/10] refactor(app): split common.cpp by responsibility common.cpp keeps the process, lock, config and log helpers. The runs move to common_run_steps.cpp (stock measurement, fan reads, put-back after a stop), common_optimize.cpp (overclock and undervolt), common_retest.cpp and common_all_in_one.cpp (fan step and the all-in-one run); the result lines to common_results.cpp; logon apply, boot on/off and updates to common_boot.cpp. Helpers several of them need are declared in common_internal.hpp. Code is moved unchanged. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 4 +- src/app/common.cpp | 1214 --------------------------------- src/app/common_all_in_one.cpp | 204 ++++++ src/app/common_boot.cpp | 215 ++++++ src/app/common_internal.hpp | 69 ++ src/app/common_optimize.cpp | 406 +++++++++++ src/app/common_results.cpp | 116 ++++ src/app/common_retest.cpp | 87 +++ src/app/common_run_steps.cpp | 188 +++++ 9 files changed, 1288 insertions(+), 1215 deletions(-) create mode 100644 src/app/common_all_in_one.cpp create mode 100644 src/app/common_boot.cpp create mode 100644 src/app/common_internal.hpp create mode 100644 src/app/common_optimize.cpp create mode 100644 src/app/common_results.cpp create mode 100644 src/app/common_retest.cpp create mode 100644 src/app/common_run_steps.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a4208db..e504f79 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -97,7 +97,9 @@ target_link_libraries(hw PUBLIC core d3d11 dxgi d3dcompiler shell32 ole32 oleaut # UI-independent application logic shared by both executables. add_library(app STATIC - src/app/common.hpp src/app/common.cpp + src/app/common.hpp src/app/common.cpp src/app/common_internal.hpp + src/app/common_run_steps.cpp src/app/common_optimize.cpp src/app/common_all_in_one.cpp + src/app/common_retest.cpp src/app/common_results.cpp src/app/common_boot.cpp src/app/guarded_gpu.hpp src/app/guarded_gpu.cpp ) target_link_libraries(app PUBLIC core hw PRIVATE gao_warnings gao_hardening) diff --git a/src/app/common.cpp b/src/app/common.cpp index 1e10e42..4655eed 100644 --- a/src/app/common.cpp +++ b/src/app/common.cpp @@ -1,31 +1,12 @@ #include "app/common.hpp" -#include "app/guarded_gpu.hpp" #define WIN32_LEAN_AND_MEAN #define NOMINMAX #include -#include -#include "core/journal.hpp" -#include "core/fan_curve.hpp" -#include "core/fan_tune.hpp" -#include "core/stability.hpp" -#include "core/task_xml.hpp" -#include "core/watchdog.hpp" #include "hw/app_files.hpp" -#include "hw/boot_task.hpp" -#include "hw/gpu_control.hpp" -#include "hw/gpu_users.hpp" -#include "hw/nvapi.hpp" -#include "hw/nvidia_app.hpp" -#include "hw/nvml.hpp" -#include "hw/stress.hpp" -#include "hw/update_io.hpp" #include "core/version.hpp" -#include #include #include -#include #include -#include namespace gao::app { @@ -85,18 +66,6 @@ std::string now_text() { return buf; } -namespace { -MeasuredRun measured_run(const StabilityResult& r) { - return {r.score, r.avg_core_mhz, r.avg_mem_mhz, r.peak_temp_c, r.end_fan_pct, r.avg_power_w}; -} - -// Both runs, or nothing: a comparison with half its numbers says nothing. -std::optional against_stock(const StabilityResult& stock, const StabilityResult& tuned) { - if (stock.score <= 0 || tuned.score <= 0) return std::nullopt; - return AgainstStock{measured_run(stock), measured_run(tuned)}; -} -} - Config load_config() { const auto text = read_file(config_path()); return text ? from_json(*text) : Config{}; @@ -165,1187 +134,4 @@ std::string decision_text(BootDecision d, const Config& c, const std::string& dr return "unknown"; } -namespace { - -// The card at stock under load, five minutes: the "before" of every result. -// It brings its own drivers and stress load, like the searches, and leaves the -// fans to the NVIDIA driver. -struct KeepAwake { - KeepAwake() { SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED); } - ~KeepAwake() { SetThreadExecutionState(ES_CONTINUOUS); } -}; - -constexpr double kLongRunS = 300; // as the soaks: the two sides of the comparison last equally long - -constexpr const char* kAutoTuningRefusal = - "Automatic Tuning is switched on in the NVIDIA app: NVIDIA's own scanner loads the card and changes its clocks when it sees fit, " - "also in the middle of a run. Switch it off (NVIDIA app, System, Performance) and start again. If the NVIDIA app shows it as off " - "already, restart the PC and look again"; - -// Why no run starts next to another tuning tool (core/search.hpp); empty when -// none runs. A list of programs that cannot be had reads as none: this check -// is a courtesy, the run's own verifications are what it relies on. -std::string other_tool() { - std::vector running; - const HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if (snap == INVALID_HANDLE_VALUE) return {}; - PROCESSENTRY32W e{}; - e.dwSize = sizeof(e); - for (BOOL more = Process32FirstW(snap, &e); more; more = Process32NextW(snap, &e)) { - std::string name; // the names looked for are ASCII; anything else cannot match - for (const wchar_t* c = e.szExeFile; *c; ++c) name += *c < 128 ? static_cast(*c) : '?'; - running.push_back(std::move(name)); - } - CloseHandle(snap); - if (const std::string tool = other_tool_refusal(running); !tool.empty()) return tool; - // The NVIDIA app may run; its Automatic Tuning may not be on. Unknown - // (nothing documents where it is kept) counts as off. - if (nvidia_auto_tuning().value_or(false)) return kAutoTuningRefusal; - return {}; -} -// How long a look at what else uses the card lasts, in samples a second apart. -constexpr int kBusyLookS = 3; - -// The look at what else uses the card, and the question about it, before a -// run (core/busy_card.hpp). A run that is one step of a longer one does not -// ask again. -BusyGate busy_before_run(const OptimizeHooks& hooks) { - if (hooks.busy_asked) return {}; - return busy_gate( - [&] { - if (hooks.measuring) hooks.measuring("Looking at what else uses the card", kBusyLookS); - auto use = gpu_use(kBusyLookS); - if (hooks.measuring) hooks.measuring("", 0); - return use; - }, - hooks.busy, [&](const std::string& m) { if (hooks.log) hooks.log(m); }); -} - -// After a measurement at stock that the temperature stopped: without load, -// this far below it before the run loads the card again, or this long. The -// steps that follow have limits of their own. -constexpr int kCoolDownC = 15; -constexpr int kCoolDownMaxS = 60; - -struct StockOutcome { - std::string error; // why no run follows: it did not run, was stopped, or the card failed at stock - StabilityResult result; -}; - -std::string stock_line(const StabilityResult& s) { - char line[400]; - std::snprintf(line, sizeof(line), " stock: %s score=%.0f it/s clock=%d MHz peak=%d C fan=%s power=%d W", verdict_name(s.verdict), - s.score, s.avg_core_mhz, s.peak_temp_c, reading(s.end_fan_pct, " %").c_str(), s.avg_power_w); - return line; -} - -StockOutcome measure_stock(const OptimizeHooks& hooks) { - StockOutcome out; - auto fail = [&](const std::string& why) { out.error = why; return out; }; - const TuningLock lock; - if (!lock.owned()) return fail("another optimize is already running (in the app or on the command line)"); - std::string why; - if (!prepare_state(&why)) return fail(why); - GuardedGpu hw(kGpu); - if (!hw.Init(&why)) return fail(why); - auto load = std::make_unique(); - if (!load->Init()) return fail("stress init failed: " + load->Error()); - const GpuControl& gpu = hw.control(); - if (!gpu.reset_to_stock || !gpu.reset_to_stock()) return fail("could not set the card to stock"); - const KeepAwake keep_awake; - auto log = [&](const std::string& m) { if (hooks.log) hooks.log(m); }; - // In every run's log, whatever the profile: a target that another tool - // left on the card makes this a measurement of something else than stock. - log(temp_target_text(gpu.temp_target_c, gpu.temp_target_default_c)); - if (hooks.measuring) hooks.measuring("Measuring the card at stock", kLongRunS); - // What the card did meanwhile, a line every ten seconds: the readings the - // measurement takes anyway, written down. Nothing of the measurement changes. - TraceClock trace; - const auto began = std::chrono::steady_clock::now(); - std::optional> unlogged; // the latest reading that got no line - const bool crashed = !guarded([&] { - out.result = run_stability( - [&] { return load->Batch(); }, - [&] { - const Telemetry t = gpu.read(); - const double at = std::chrono::duration(std::chrono::steady_clock::now() - began).count(); - if (t.ok && trace.due(at)) { - log(trace_line(at, t)); - unlogged.reset(); - } else if (t.ok) { - unlogged = std::make_pair(at, t); - } - return t; - }, - kLongRunS, kStockTempC, hooks.aborted); - }); - if (hooks.measuring) hooks.measuring("", 0); - if (crashed) { - (void)load.release(); // as in run_optimize: a load that faulted is left alone - return fail("access violation while the card was measured at stock"); - } - if (unlogged) log(trace_line(unlogged->first, unlogged->second)); // how it ended, when that fell between two lines - log(stock_line(out.result)); - out.error = stock_refusal(out.result); - if (!out.error.empty() || out.result.verdict != Verdict::TooHot) return out; - // The driver's own fan control let the card reach the limit. That is the - // "before"; the run goes on, with the fans on a curve from here. - log(" the card reached " + std::to_string(out.result.peak_temp_c) + " C at stock with the driver's own fan control (fans at " + - reading(out.result.end_fan_pct, " %") + "): the measurement stopped there, after " + - std::to_string(static_cast(out.result.seconds)) + " s. The run goes on with the fans on a curve."); - if (hooks.measuring) hooks.measuring("Letting the card cool down", kCoolDownMaxS); - for (int i = 0; i < kCoolDownMaxS * 4 && !(hooks.aborted && hooks.aborted()); ++i) { - const Telemetry t = gpu.read(); - if (!t.ok || t.temp_c <= kStockTempC - kCoolDownC) break; - Sleep(250); - } - if (hooks.measuring) hooks.measuring("", 0); - return out; -} - -// One reading of a run, with the fans moved along their curve. -Telemetry read_with_fans(const GpuControl& gpu, FanDriver& fans, const OptimizeHooks& hooks) { - const Telemetry t = gpu.read(); - const FanMode before = fans.state().mode; - const FanState now = fans.tick(t.temp_c, t.power_w, std::chrono::steady_clock::now(), t.power_limit_w); - if (now.mode != before && now.mode == FanMode::Failed && hooks.log) hooks.log("fans: a write did not verify; the driver has them again"); - if (now.mode != before && now.mode == FanMode::Foreign && hooks.log) hooks.log("fans: another program set them; leaving them alone"); - return t; -} - -// The fans of a run back to the driver, on every exit: done, aborted, failed -// or thrown. -struct FanRelease { - FanDriver& f; - const GuardedGpu& hw; - const GpuControl& gpu; - const OptimizeHooks& hooks; - ~FanRelease() { - f.release(); - // A hand-back that did not reach the driver, from a fan driver - // that failed earlier or from the release() above, is remembered - // by `hw`; the fans may still be at a manual speed. One more - // attempt, and only then: a hand-back that was delivered is not - // repeated, it would take the fans from another program that set - // them since. Hand back only, never a speed. - if (hw.FanAutoOwed() && gpu.set_fan_auto) gpu.set_fan_auto(); - if (!hw.FanAutoOwed()) return; - // No reconnect at exit, so nothing will deliver it any more. A - // destructor must not throw; the line is lost if it cannot be built. - try { - if (hooks.log) - hooks.log("fans: could not be handed back to the driver; they may still be at a manual speed -- run `gao --fan auto`"); - } catch (...) { - } - } -}; - -} - -std::vector busy_now() { return busy_programs(gpu_use(kBusyLookS)); } - -std::optional tune_before_run() { - const Config cfg = load_config(); - if (!cfg.profile) return std::nullopt; - GuardedGpu hw(kGpu); - std::string why; - if (!hw.Init(&why) || !tune_on_card(hw.control(), *cfg.profile)) return std::nullopt; - return cfg.profile; -} - -bool put_back_after_stop(const std::optional& before, const OptimizeHooks& hooks) { - if (!before) return false; - auto log = [&](const std::string& m) { if (hooks.log) hooks.log(m); }; - const TuningLock lock; // the watchdog keeps out of it meanwhile - GuardedGpu hw(kGpu); - std::string why; - if (!hw.Init(&why)) { - log("stopped: the tune that was on the card could not be put back (" + why + ")"); - return false; - } - const GpuControl& gpu = hw.control(); - if (tune_on_card(gpu, *before)) return true; // stopped before the card was touched - bool clean = false; - if (!apply_profile(gpu, *before, &why, &clean)) { - log("stopped: the tune that was on the card could not be put back (" + why + ") -- " + - (clean ? "card at stock" : "reset to stock FAILED, run `gao --reset`")); - return false; - } - log("stopped: the tune that was on the card before the run is back: " + profile_text(*before)); - return true; -} - -OptimizeOutcome run_optimize(Preset preset, const OptimizeHooks& hooks, const std::optional& fan_curve, - const std::optional& known_stock) { - OptimizeOutcome out; - auto log = [&](const std::string& m) { if (hooks.log) hooks.log(m); }; - auto fail = [&](const std::string& why) { out.error = why; return out; }; - if (preset == Preset::Undervolt) return fail("the undervolt is not a clock search; it has its own run"); - if (const std::string tool = other_tool(); !tool.empty()) return fail(tool); - if (!is_elevated()) return fail("optimizing changes clocks and power limits and needs administrator rights"); - const TuningLock lock; - if (!lock.owned()) return fail("another optimize is already running (in the app or on the command line)"); - std::string why; - if (!prepare_state(&why)) return fail(why); - const BusyGate busy = busy_before_run(hooks); - if (!busy.refusal.empty()) return fail(busy.refusal); - // Before the search and with drivers of its own, so that only one stress - // load exists at a time. The lock is taken again there, on this thread. - if (known_stock) { - out.stock = *known_stock; - } else { - log("the card at stock, five minutes"); - const StockOutcome measured = measure_stock(hooks); - if (!measured.error.empty()) return fail(measured.error); - out.stock = measured.result; - } - // Every driver call of the run goes through `hw`: guarded against a fault - // inside the driver DLLs, and reconnected by gpu.recover after a driver - // reset. `gpu` is never reassigned, so the references the fan driver, the - // search and the emergency handlers hold stay valid across a reconnect. - GuardedGpu hw(kGpu); - if (!hw.Init(&why)) return fail(why); - // On the heap, so the path that follows an access violation during the - // search can leave it alone instead of destroying it (see there). - auto load = std::make_unique(); - if (!load->Init()) return fail("stress init failed: " + load->Error()); - const GpuControl& gpu = hw.control(); - // The profile's curve drives the fans for the whole run, so the clocks it - // finds hold at the temperatures that curve produces. - const FanCurve curve = fan_curve.value_or(default_curve(preset)); - FanDriver fans(gpu, curve, objectives_for(preset).max_temp_c, fan_min_for(load_config(), hw.GpuUuid(), gpu.fan_min_pct)); - const FanRelease fan_release{fans, hw, gpu, hooks}; - if (!gpu.set_fan_pct) log("fans: not controllable on this card; the driver keeps them"); - - const auto path = journal_path(); - if (path.empty()) return fail("the ProgramData folder could not be resolved; cannot keep the crash journal"); - const auto lines = read_lines(path); - if (!lines) return fail("the crash journal " + path.string() + " exists but cannot be read; not tuning without it"); - // What a memory edge in the journal is bound to: this card with this driver. - const std::string rig_card = hw.GpuUuid(), rig_driver = hw.DriverVersion(); - Journal journal(*lines, [&path](const std::string& l) { return append_line_durable(path, l); }, - rig_card.empty() || rig_driver.empty() ? std::string() : rig_card + " / " + rig_driver); - // Prove the journal is writable before any clock is touched; the parser - // ignores lines without an id, so this one never becomes a ceiling. - if (!append_line_durable(path, "{\"session\":\"" + now_text() + "\"}")) - return fail("cannot write the crash journal " + path.string() + "; not tuning without it"); - for (const auto& f : journal.freeze_entries()) - log("warning: a previous run froze the machine at " + f.description + - (f.caps_anything ? "; staying below it from now on" : "; there is no setting to stay below")); - if (!gpu.set_power_limit) log("power limit: not adjustable on this card, skipped"); - - // A run lasts minutes with a candidate applied and a journal entry open; - // idle sleep must not interrupt it. The display may still turn off. - struct KeepAwake { - KeepAwake() { SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED); } - ~KeepAwake() { SetThreadExecutionState(ES_CONTINUOUS); } // every exit - } keep_awake; - OptimizeIo io; - io.probe = [&](double seconds, int max_temp, double stall_below) { - return run_stability([&] { return load->Batch(); }, [&] { return read_with_fans(gpu, fans, hooks); }, seconds, max_temp, - hooks.aborted, stall_below); - }; - io.aborted = hooks.aborted; - io.log = hooks.log; - io.bandwidth = [&] { - const auto gbps = load->MeasureBandwidth(); - if (!gbps) log("bandwidth measurement failed: " + load->Error()); - return gbps; - }; - io.bandwidth_unsettled = [&] { return load->BandwidthUnsettled(); }; - // The wait between a driver reset and the next load. The card is left - // alone: no load, no driver call, no fan tick, and no log line (the search - // says that it rests). Short slices, so a stop request ends the wait - // within a quarter of a second. - io.rest = [&hooks](double seconds) { - const auto stop_requested = [&hooks] { return hooks.aborted && hooks.aborted(); }; - if (stop_requested()) return false; - if (!(seconds > 0)) return true; // nothing to wait for - using namespace std::chrono; - const auto end = steady_clock::now() + duration_cast(duration(std::min(seconds, 3600.0))); - for (;;) { - const auto left_ms = duration_cast(end - steady_clock::now()).count(); - if (left_ms <= 0) return true; - Sleep(static_cast(left_ms < 250 ? left_ms : 250)); - if (stop_requested()) return false; - } - }; - // After a driver reset the load may hold a removed device without having - // noticed. A fresh one is built here, outside any probe, so the health - // probe times the card and not the load's start-up. - // Not guarded here: a fault inside D3D reaches the guard around the - // search, like every other call of the load, and the run ends through the - // crashed path, which never touches the load again. - io.prepare_load = [&] { - if (load->Recreate()) return true; - 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 - // journal entry open. The frames of the search are skipped, not unwound; - // everything that must run on the way out lives in this frame. A C++ - // exception passes through the guard to the catch below. - bool crashed = false; - try { - crashed = !guarded([&] { out.result = optimize(gpu, objectives_for(preset), journal, io, out.stock); }); - } catch (...) { // never leave a candidate applied, whatever went wrong - if (hooks.active_gpu) hooks.active_gpu(nullptr); - if (gpu.reset_to_stock) gpu.reset_to_stock(); - // The machine did not freeze: an entry left open would become a - // ceiling the next run stays below. - if (journal.open_id() >= 0) journal.complete(journal.open_id(), "CRASHED"); - throw; - } - if (crashed) { - // An access violation somewhere in the search: in a driver DLL, in the - // stress load's D3D calls or in our own code. A candidate may still be - // applied and its journal entry open. - // - // Leaked on purpose, and before anything that can throw, so that no - // exception from the steps below destroys it on the way out. The - // fault skipped the frames of the search, so a D3D call of the stress - // load may have been abandoned halfway; its destructor would release - // COM objects into that same user-mode driver and could fault outside - // any guard, or hang. `gao` is about to report the failure and exit, - // and the device goes with the process. The window application lives - // on: there the leaked load, with its device and buffers, stays until - // the application exits. - (void)load.release(); - bool stock = false; - { - // The emergency handler stays registered until the card and the - // journal are dealt with: the steps below can take half a minute, - // and without it Ctrl+C would end the process and a closed window - // would reset nothing. Unregistered when this block ends, also by - // an exception, and always before `hw` is destroyed. - struct Unregister { - const OptimizeHooks& h; - ~Unregister() { if (h.active_gpu) h.active_gpu(nullptr); } - } unregister{hooks}; - log("access violation during the search -- resetting the card to stock"); - // The machine did not freeze, so the candidate must not become a - // ceiling. Closing the entry is a file append, no driver call, so - // it comes first: nothing that happens during the reset can leave - // it open. - if (journal.open_id() >= 0 && !journal.complete(journal.open_id(), "CRASHED")) - log("warning: the crash journal entry could not be closed; later runs will stay below this candidate"); - // The connection is often still good (the fault may have been - // outside NVML and NVAPI), so reset first; reconnecting costs - // seconds with the candidate applied. - stock = gpu.reset_to_stock && gpu.reset_to_stock(); - if (!stock && gpu.reset_to_stock && gpu.recover) { - log("the reset failed; reconnecting to the driver, up to 30 s"); - stock = gpu.recover() && gpu.reset_to_stock(); - } - log(std::string("the search ended in an access violation -- ") + - (stock ? "card restored to stock" : "reset FAILED, run `gao --reset`")); - } - out.ran = true; - // optimize never returned, so out.result was never assigned: every - // field not set here keeps its default. driver_resets reads 0, - // whatever the search had counted before the fault. - out.result.ok = false; - out.result.stock_restored = stock; - out.result.reason = "access violation during the search"; - return out; - } - if (hooks.active_gpu) hooks.active_gpu(nullptr); - out.ran = true; - // A second look, now that the load is off the card: a program that - // started or stopped using it during the run moved the numbers by several - // percent, and the measurement at stock was taken without that. - if (!hooks.busy_asked && !(hooks.aborted && hooks.aborted())) { - out.busy_note = busy_change(busy.programs, busy_programs(gpu_use(kBusyLookS))); - if (!out.busy_note.empty()) log("note: " + out.busy_note + "; its numbers and the ones at stock were not measured on the same card"); - } - if (!out.result.ok) return out; - - const std::string driver = hw.DriverVersion(), gpu_id = hw.GpuUuid(); - if (driver.empty() || gpu_id.empty()) { - out.save_note = "NVML did not report the driver version or GPU id, so the profile could never be re-applied"; - return out; - } - Config cfg = load_config(); - cfg.profile = Profile{preset, out.result.power_pct, out.result.core_mhz, out.result.mem_mhz, driver, gpu_id, now_text()}; - cfg.profile->against_stock = against_stock(out.stock, out.result.soak); - cfg.fan_curve.reset(); // a new tune starts from its own curve - // Only a curve that actually drove the fans counts as tested. - if (gpu.set_fan_pct && fans.state().mode != FanMode::Failed && fans.state().mode != FanMode::Foreign) { - cfg.profile->fan_curve = curve; // what the run was tested with - cfg.fan_control = true; - if (fans.state().min_pct > fan_min_for(cfg, gpu_id, gpu.fan_min_pct)) { // learned during the run - cfg.fan_min_pct = fans.state().min_pct; - cfg.fan_min_gpu = gpu_id; - } - } - cfg.boot_strikes = 0; // strikes belong to the profile they were earned by - out.saved = save_config(cfg); - if (!out.saved) out.save_note = "could not write " + config_path().string(); - return out; -} - -UndervoltOutcome run_undervolt(const OptimizeHooks& hooks, bool on_saved_tune, const UndervoltOnTune& how) { - UndervoltOutcome out; - auto log = [&](const std::string& m) { if (hooks.log) hooks.log(m); }; - auto fail = [&](const std::string& why) { out.error = why; return out; }; - if (const std::string tool = other_tool(); !tool.empty()) return fail(tool); - if (!is_elevated()) return fail("undervolting changes the card's voltage/frequency curve and needs administrator rights"); - const TuningLock lock; - if (!lock.owned()) return fail("another optimize is already running (in the app or on the command line)"); - std::string why; - if (!prepare_state(&why)) return fail(why); - if (const BusyGate busy = busy_before_run(hooks); !busy.refusal.empty()) return fail(busy.refusal); - // The card at stock first, as every run measures it: the "before" of the - // result, with the fans left to the driver, stopped at kStockTempC and - // kept when it was, and followed by a cool-down (measure_stock). With - // drivers of its own, before this run makes its own, as in run_optimize. - // Not on top of the saved tune: the reference there is that overclock. - std::optional stock_run; - if (!on_saved_tune) { - log("the card at stock, five minutes"); - const StockOutcome measured = measure_stock(hooks); - if (!measured.error.empty()) return fail(measured.error); - stock_run = measured.result; - } - GuardedGpu hw(kGpu); // guarded driver calls and a reconnect, as in run_optimize - if (!hw.Init(&why)) return fail(why); - auto load = std::make_unique(); // on the heap: left alone after an access violation - if (!load->Init()) return fail("stress init failed: " + load->Error()); - const GpuControl& gpu = hw.control(); - // The fans follow a curve for the whole search, as in run_optimize: the - // one in use (the overclock's own, in an all-in-one run), or the preset's. - // Left to the driver, some cards get hotter under this load than the - // search allows (a 3080 Ti reached 85 C at stock with its fans at 65 %), - // and every probe would end TOO HOT. - const Objectives obj = objectives_for(Preset::Undervolt); - const Config at_start = load_config(); - // On a card that reached more than the profile's limit in the reference - // the search runs with a higher one (temp_ceiling), and so do the fans: - // with "100 % from 75 C" they would hold a card that reaches 86 C by - // itself at 74 C and be far louder than at stock (93 % against 66 % on - // the tester's RTX 3080 Ti). The curve moves up with the limit, by as - // much as the limit is above the one the curve was drawn against: a curve - // saved by a run on this card was drawn against that run's ceiling. - const std::optional& reference = on_saved_tune ? how.measured : stock_run; - const int ceiling = reference ? temp_ceiling(obj.max_temp_c, reference->peak_temp_c, gpu.temp_target_c) : obj.max_temp_c; - const int drawn_against = at_start.profile ? profile_ceiling(*at_start.profile) : obj.max_temp_c; - const FanCurve fan_curve = - shifted_curve(active_fan_curve(at_start).value_or(default_curve(Preset::Undervolt)), ceiling - drawn_against); - if (ceiling > obj.max_temp_c) - log("fans: on this card the limit is " + std::to_string(ceiling) + " C, not the profile's " + std::to_string(obj.max_temp_c) + - " C" + (ceiling > drawn_against ? "; the fan curve moves up with it by " + std::to_string(ceiling - drawn_against) + " C" - : std::string("; the fan curve was drawn for it"))); - FanDriver fans(gpu, fan_curve, ceiling, fan_min_for(at_start, hw.GpuUuid(), gpu.fan_min_pct)); - const FanRelease fan_release{fans, hw, gpu, hooks}; - if (!gpu.set_fan_pct) log("fans: not controllable on this card; the driver keeps them"); - - const auto path = journal_path(); - if (path.empty()) return fail("the ProgramData folder could not be resolved; cannot keep the crash journal"); - const auto lines = read_lines(path); - if (!lines) return fail("the crash journal " + path.string() + " exists but cannot be read; not tuning without it"); - Journal journal(*lines, [&path](const std::string& l) { return append_line_durable(path, l); }); - if (!append_line_durable(path, "{\"session\":\"" + now_text() + "\"}")) - return fail("cannot write the crash journal " + path.string() + "; not tuning without it"); - - struct KeepAwake { - KeepAwake() { SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED); } - ~KeepAwake() { SetThreadExecutionState(ES_CONTINUOUS); } - } keep_awake; - UndervoltIo io; - io.probe = [&](double seconds, int max_temp, double stall_below) { - return run_stability([&] { return load->Batch(); }, [&] { return read_with_fans(gpu, fans, hooks); }, seconds, max_temp, - hooks.aborted, stall_below); - }; - io.aborted = hooks.aborted; - io.log = hooks.log; - io.measuring = hooks.measuring; - io.rest = [&hooks](double seconds) { // no load, no driver call; a stop request ends it within a quarter of a second - using namespace std::chrono; - const auto end = steady_clock::now() + duration_cast(duration(std::min(seconds, 3600.0))); - for (;;) { - if (hooks.aborted && hooks.aborted()) return false; - const auto left_ms = duration_cast(end - steady_clock::now()).count(); - if (left_ms <= 0) return true; - Sleep(static_cast(left_ms < 250 ? left_ms : 250)); - } - }; - io.prepare_load = [&] { - if (load->Recreate()) return true; - log("the stress load could not be rebuilt: " + load->Error()); - return false; - }; - - // The overclock the undervolt goes on top of, when asked for: the saved - // tune, if it is this card's and this driver's (strikes only gate the - // logon apply). - UndervoltBase base; - base.measured = stock_run; - std::optional over; - if (on_saved_tune) { - Config cfg = load_config(); - cfg.boot_strikes = 0; - const std::string driver_now = hw.DriverVersion(); - const BootDecision d = decide_boot(cfg, driver_now, hw.GpuUuid()); - if (d != BootDecision::Apply) return fail("no saved tune to undervolt: " + decision_text(d, cfg, driver_now)); - over = cfg.profile; - base = UndervoltBase{over->power_pct, over->core_mhz, over->mem_mhz, how.measured, how.clock_khz}; - log("on top of the saved tune: power " + std::to_string(base.power_pct) + " %, core +" + std::to_string(base.core_mhz) + - " MHz, mem +" + std::to_string(base.mem_mhz) + " MHz"); - } - if (hooks.active_gpu) hooks.active_gpu(&gpu); - bool crashed = false; - try { - crashed = !guarded([&] { out.result = find_undervolt(gpu, journal, io, obj.max_temp_c, obj.perf_push, base); }); - } catch (...) { // never leave a candidate applied, whatever went wrong - if (hooks.active_gpu) hooks.active_gpu(nullptr); - if (gpu.reset_to_stock) gpu.reset_to_stock(); - if (journal.open_id() >= 0) journal.complete(journal.open_id(), "CRASHED"); - throw; - } - if (crashed) { - // As in run_optimize: the frames of the search were skipped, the load - // may be halfway through a D3D call and is left alone. - (void)load.release(); - log("access violation during the search -- resetting the card to stock"); - if (journal.open_id() >= 0 && !journal.complete(journal.open_id(), "CRASHED")) - log("warning: the crash journal entry could not be closed; later runs will stay below this candidate"); - bool stock = gpu.reset_to_stock && gpu.reset_to_stock(); - if (!stock && gpu.reset_to_stock && gpu.recover) stock = gpu.recover() && gpu.reset_to_stock(); - out.result = {}; - out.result.reason = "access violation during the search"; - out.result.stock_restored = stock; - } - if (hooks.active_gpu) hooks.active_gpu(nullptr); - out.ran = true; - if (!out.result.ok) return out; - - const std::string driver = hw.DriverVersion(), gpu_id = hw.GpuUuid(); - if (driver.empty() || gpu_id.empty()) { - out.save_note = "NVML did not report the driver version or GPU id, so the profile could never be re-applied"; - return out; - } - Config cfg = load_config(); - Profile p{Preset::Undervolt, 100, 0, 0, driver, gpu_id, now_text()}; - // The curve the fans followed before stays theirs: it is the one the - // search ran with. - if (cfg.profile) p.fan_curve = cfg.profile->fan_curve; - if (over) { // the overclock it was searched on keeps everything it had; the undervolt joins it - p = *over; - p.saved_at = now_text(); - if (how.save_as) p.preset = *how.save_as; - } - // The limit the tune was found with goes with it: the fans, the Fan page - // and a later test of the tune use it (profile_ceiling). - if (ceiling > objectives_for(p.preset).max_temp_c) p.temp_ceiling_c = ceiling; - else p.temp_ceiling_c.reset(); - // Only a curve that actually drove the fans counts as tested. - if (gpu.set_fan_pct && fans.state().mode != FanMode::Failed && fans.state().mode != FanMode::Foreign) { - if (ceiling > drawn_against) { - // The curve moved for this run: it is the one that was tested, and - // the one drawn against the limit that is saved with it. An edited - // curve went into it and does not stay next to it. - p.fan_curve = fan_curve; - cfg.fan_curve.reset(); - } - if (!p.fan_curve) p.fan_curve = fan_curve; - cfg.fan_control = true; - } - p.undervolt = UndervoltTune{out.result.applied_uv, out.result.freq_khz, out.result.raise_khz}; - // On a saved tune the baseline is that tune, not stock: the stock run it was saved with stays. - if (!over) p.against_stock = against_stock(out.result.baseline, out.result.after); - else if (p.against_stock && out.result.after.score > 0) p.against_stock->tuned = measured_run(out.result.after); - cfg.profile = p; - cfg.boot_strikes = 0; // strikes belong to the profile they were earned by - out.saved = save_config(cfg); - if (!out.saved) out.save_note = "could not write " + config_path().string(); - return out; -} - -BootApplyOutcome apply_at_logon() { - BootApplyOutcome out; - auto done = [&](const std::string& msg) { out.message = msg; boot_log(msg); return out; }; - Nvml nvml; - if (!nvml.Init()) return done("NVML init failed: " + nvml.Error()); - Config cfg = load_config(); - const std::string driver = nvml.DriverVersion(); - const auto d = decide_boot(cfg, driver, nvml.GpuUuid(kGpu)); - out.decision = d; - if (d != BootDecision::Apply) return done("not applied: " + decision_text(d, cfg, driver)); - // The strike is on disk before the hardware is touched: a crash from here - // on counts. - ++cfg.boot_strikes; - if (!save_config(cfg)) return done("could not record the strike; not applying"); - // A strike is for a logon that may have crashed the machine. An apply - // that ended here, with nothing of the profile on the card, did not: the - // strike is taken back, or three refused logons would read as "disabled - // after 3 crashes". - auto no_strike = [&] { - --cfg.boot_strikes; - if (!save_config(cfg)) boot_log("could not take the strike back; it stays"); - }; - Nvapi nvapi; - if (!nvapi.Init()) { no_strike(); return done("NVAPI init failed: " + nvapi.Error()); } - std::string why; - bool left_clean = false; - if (!apply_profile(make_gpu_control(nvml, nvapi, kGpu), *cfg.profile, &why, &left_clean)) { - if (left_clean) no_strike(); - return done("apply failed: " + why); - } - out.applied = true; - out.profile = *cfg.profile; - return done("applied " + profile_text(*cfg.profile) + ", strike " + std::to_string(cfg.boot_strikes) + - " clears in 2 minutes"); -} - -void clear_boot_strike() { - // Reload: an optimize may have saved a new profile meanwhile; only the - // counter is ours to change. If the file cannot be read, or reads without - // a profile, writing would destroy it: leave the strike (fails safe). - for (int attempt = 0; attempt < 3; ++attempt) { - const auto text = read_file(config_path()); - Config latest = text ? from_json(*text) : Config{}; - if (!latest.profile) { boot_log("could not re-read gao.json to clear the strike; it stays"); return; } - latest.boot_strikes = 0; - if (save_config(latest)) { boot_log("ran 2 minutes without a crash; strike cleared"); return; } - Sleep(1000); - } - boot_log("could not save gao.json to clear the strike; it stays"); -} - -bool enable_boot(std::string* message) { - auto say = [&](const std::string& m, bool ok) { if (message) *message = m; return ok; }; - if (!is_elevated()) return say("boot-apply needs administrator rights", false); - std::string why; - if (!prepare_state(&why)) return say(why, false); - const std::string sid = current_user_sid(); - if (sid.empty()) return say("could not determine the current user's SID", false); - wchar_t self[MAX_PATH]; - const DWORD n = GetModuleFileNameW(nullptr, self, MAX_PATH); - if (n == 0 || n >= MAX_PATH) return say("could not find this program's own path", false); - if (!install_app(std::filesystem::path(self).parent_path(), &why)) - return say(why, false); - // The logon task starts the tray app, which applies the tune and stays - // resident to keep it applied: no time limit. - const auto exe = installed_tray_path(); - const auto u8 = exe.u8string(); - const std::string xml = boot_task_xml(std::string(u8.begin(), u8.end()), "--tray", sid, "PT0S"); - if (const long code = boot_task_create(xml); code < 0) { - char hex[16]; - std::snprintf(hex, sizeof(hex), "%08lX", static_cast(code)); - return say(std::string("could not create the task (Task Scheduler error 0x") + hex + ")", false); - } - // Not a reason to fail: the task is there, and the tray icon still opens the app. - const bool in_start_menu = start_menu_entry_create() >= 0; - Config cfg = load_config(); - cfg.boot_strikes = 0; - if (!save_config(cfg)) return say("task created, but could not reset the strike counter", false); - return say("boot-apply on: " + exe.string() + " --tray runs at every logon and keeps the tune applied (strikes reset)" + - (in_start_menu ? "" : "; the Start menu entry could not be made") + - (cfg.profile ? "" : "; note: there is no saved profile yet, optimize first"), true); -} - -bool disable_boot(std::string* message) { - auto say = [&](const std::string& m, bool ok) { if (message) *message = m; return ok; }; - if (!is_elevated()) return say("boot-apply needs administrator rights", false); - std::string why; - if (!prepare_state(&why)) return say(why, false); - const bool task_removed = boot_task_remove() >= 0; - start_menu_entry_remove(); - const bool removed_now = uninstall_app(); - return say(std::string("boot-apply off: task ") + (task_removed ? "removed" : "not removed") + ", installed copy " + - (removed_now ? "removed" : "in use by the running tray app; it is removed at the next restart"), - task_removed); -} - -namespace { -std::filesystem::path own_dir() { - wchar_t self[MAX_PATH]; - const DWORD n = GetModuleFileNameW(nullptr, self, MAX_PATH); - return n == 0 || n >= MAX_PATH ? std::filesystem::path() : std::filesystem::path(self).parent_path(); -} -} - -bool logon_copy_is_this_build() { - const auto dir = own_dir(); - if (dir.empty()) return false; - return files_equal(dir / L"gao.exe", installed_exe_path()) && files_equal(dir / L"GpuAutoOptimizer.exe", installed_tray_path()); -} - -bool logon_copy_outdated() { - if (!boot_task_exists()) return false; - const auto dir = own_dir(); - std::error_code ec; - if (dir.empty() || std::filesystem::equivalent(dir, installed_dir(), ec)) return false; - if (!std::filesystem::exists(installed_exe_path(), ec) || logon_copy_is_this_build()) return false; - // Never a downgrade: an old zip that is still lying around must not - // replace a newer installed copy just because it was started. - const auto installed = file_version_number(installed_exe_path()); - return !(installed && *installed > kVersionNumber); -} - -void ensure_start_menu_entry() { - std::error_code ec; - if (is_elevated() && boot_task_exists() && !start_menu_entry_exists() && std::filesystem::exists(installed_tray_path(), ec)) - start_menu_entry_create(); -} - -std::string update_logon_copy() { - if (!is_elevated() || !logon_copy_outdated()) return {}; - std::string message; - if (!enable_boot(&message)) return "The copy that starts at logon could not be updated: " + message; - return "The copy that starts at logon was updated to this version (" + std::string(kVersion) + ")."; -} - -UpdateCheck check_for_update() { - UpdateCheck out; - const auto body = https_get("https://api.github.com/repos/Rovey/gpu-auto-optimizer/releases/latest", 1024 * 1024, &out.error); - if (!body) return out; - auto release = parse_latest_release(*body); - if (!release) { out.error = "the answer did not describe a release"; return out; } - if (*version_number(release->version) > kVersionNumber) out.release = std::move(release); - return out; -} - -bool own_folder_writable() { - const auto dir = own_dir(); - if (dir.empty()) return false; - const auto probe = dir / (L"write-test-" + std::to_wstring(GetCurrentProcessId()) + L".tmp"); - const HANDLE file = CreateFileW(probe.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, - FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE, nullptr); - if (file == INVALID_HANDLE_VALUE) return false; - CloseHandle(file); - return true; -} - -bool install_update(const ReleaseInfo& release, std::string* message) { - auto say = [&](const std::string& m, bool ok) { if (message) *message = m; return ok; }; - const auto dir = own_dir(); - if (dir.empty()) return say("could not find this program's own folder", false); - // Elevated, the download is unpacked where only administrators can write: - // between the hash check and the copy, nobody else may swap the files. - std::filesystem::path work; - std::string why; - if (is_elevated()) { - if (!prepare_state(&why)) return say(why, false); - work = app_dir() / L"update"; - } else { - std::error_code ec; - work = std::filesystem::temp_directory_path(ec) / L"GpuAutoOptimizer-update"; - if (ec) return say("no temporary folder", false); - } - std::error_code ec; - std::filesystem::remove_all(work, ec); - const auto unpacked = work / L"files"; - if (!std::filesystem::create_directories(unpacked, ec)) return say("could not create " + work.string(), false); - struct Cleanup { - std::filesystem::path p; - ~Cleanup() { std::error_code e; std::filesystem::remove_all(p, e); } - } cleanup{work}; - - // What to do and in which order is install_release's (core, tested with a - // fake source); here are only the real steps. - const auto zip = work / L"update.zip"; - UpdateSteps steps; - steps.download = [&](const std::string& url, std::string* error) { return https_download(url, zip, 64 * 1024 * 1024, error); }; - steps.sha256 = [&] { return sha256_hex(zip); }; - steps.unpack = [&](std::string* error) { return extract_zip(zip, unpacked, error); }; - steps.unpacked_is = [&](unsigned version) { - for (const wchar_t* name : kAppExes) - if (file_version_number(unpacked / name) != version) return false; - return true; - }; - steps.replace = [&](std::string* error) { return copy_app(unpacked, dir, error); }; - return install_release(release, steps, message); -} - -namespace { - -// The card with the saved tune under load while its fans are tuned: a -// measuring step of the all-in-one run. It brings its own drivers and stress -// load, like the searches and like measure_stock above. -struct FanStepOutcome { - std::string error; // why the step ended without a result; the card is at stock then - FanTuneResult fan; - StabilityResult final; // the five minutes with everything applied; seconds 0 when not run - std::string note; // what was kept instead, when the tuned curve was not -}; - -// Steps 4 and 5: the saved tune applied, the fans tuned under load, then five -// minutes with the fans on the tuned curve. Saves the curve into the profile -// when that run is stable. A stop request ends at stock. -FanStepOutcome fan_step(const OptimizeHooks& hooks, int start_pct) { - FanStepOutcome out; - auto log = [&](const std::string& m) { if (hooks.log) hooks.log(m); }; - auto fail = [&](const std::string& why) { out.error = why; return out; }; - const TuningLock lock; - if (!lock.owned()) return fail("another optimize is already running (in the app or on the command line)"); - std::string why; - if (!prepare_state(&why)) return fail(why); - GuardedGpu hw(kGpu); - if (!hw.Init(&why)) return fail(why); - auto load = std::make_unique(); - if (!load->Init()) return fail("stress init failed: " + load->Error()); - const GpuControl& gpu = hw.control(); - Config cfg = load_config(); - if (!cfg.profile) return fail("there is no saved tune to tune the fans for"); - const Profile profile = *cfg.profile; - const int limit = profile_ceiling(profile); - const int min_pct = fan_min_for(cfg, hw.GpuUuid(), gpu.fan_min_pct); - // Every way out: the fans go back to the driver. The app's own fan curve - // driver takes them from there. - struct FansBack { - const GpuControl& gpu; - ~FansBack() { if (gpu.set_fan_auto) gpu.set_fan_auto(); } - } fans_back{gpu}; - auto to_stock = [&](const std::string& reason) { - const bool stock = gpu.reset_to_stock && gpu.reset_to_stock(); - return fail(reason + (stock ? " -- card at stock" : " -- reset to stock FAILED, run `gao --reset`")); - }; - if (!apply_profile(gpu, profile, &why)) return fail("the tune could not be applied for the fan tune: " + why); - - const KeepAwake keep_awake; - std::optional fans; // drives the curve under test during the final run - const Probe probe = [&](double seconds, int max_temp, double stall_below) { - auto read = [&] { - const Telemetry t = gpu.read(); - if (fans) fans->tick(t.temp_c, t.power_w, std::chrono::steady_clock::now(), t.power_limit_w); - return t; - }; - return run_stability([&] { return load->Batch(); }, read, seconds, max_temp, hooks.aborted, stall_below); - }; - FanTuneIo io; - io.probe = probe; - io.aborted = hooks.aborted; - io.log = hooks.log; - io.measuring = hooks.measuring; - const bool crashed = !guarded([&] { - out.fan = tune_fan(gpu, io, limit, start_pct, min_pct); - if (hooks.aborted && hooks.aborted()) return; - // The tuned curve, or the profile's own when the tune gave none. - const FanCurve curve = out.fan.ok ? out.fan.curve : active_fan_curve(cfg).value_or(default_curve(profile.preset)); - // The tune left the fans on a speed set by hand. The curve driver - // would take a speed it did not set itself for another program's and - // step aside: it starts from driver control. - if (gpu.set_fan_auto) gpu.set_fan_auto(); - if (gpu.set_fan_pct) fans.emplace(gpu, curve, limit, min_pct); - log(" final test: " + std::to_string(static_cast(kLongRunS)) + " s with everything applied"); - if (hooks.measuring) hooks.measuring("Final test: everything together", kLongRunS); - out.final = probe(kLongRunS, limit, 0.0); - if (hooks.measuring) hooks.measuring("", 0); - if (fans) fans->release(); - fans.reset(); - }); - if (crashed) { - (void)load.release(); - return to_stock("access violation while the fans were tuned"); - } - if ((hooks.aborted && hooks.aborted()) || out.final.verdict == Verdict::Aborted) return to_stock("aborted"); - if (out.final.seconds > 0 && out.final.verdict != Verdict::Stable) { - // With the fans this quiet (or at all) the whole did not hold five - // minutes. The tune passed its own soaks with the fans it had then: - // it stays, the tuned curve does not. After a driver reset the card - // is at stock and the tune is put back. - out.note = std::string("the final test ended ") + verdict_name(out.final.verdict) + "; the tuned fan curve is not kept"; - log(" " + out.note); - out.fan.ok = false; - const bool lost = out.final.verdict == Verdict::DeviceLost || out.final.verdict == Verdict::Stalled || - out.final.verdict == Verdict::NoTelemetry; - if (lost && !(gpu.recover && gpu.recover() && apply_profile(gpu, profile, &why))) - return to_stock("the tune could not be put back after a driver reset in the final test"); - return out; - } - if (!out.fan.ok) { - out.note = "the fans were not tuned (" + out.fan.reason + "); the profile's own curve is kept"; - return out; - } - cfg = load_config(); // only the curve is this step's to change - if (cfg.profile) { - cfg.profile->fan_curve = out.fan.curve; - cfg.fan_curve.reset(); - cfg.fan_control = true; - if (!save_config(cfg)) out.note = "the tuned fan curve could not be saved"; - } - return out; -} - -} - -int driver_resets(const OptimizeOutcome& o) { return o.ran ? o.result.driver_resets : 0; } -int driver_resets(const UndervoltOutcome& o) { return o.ran ? o.result.driver_resets : 0; } -int driver_resets(const AllInOneOutcome& o) { return driver_resets(o.overclock) + driver_resets(o.undervolt); } - -namespace { - -// One measurement, as the result lines show it. with_mem: the memory clock too. -std::string measured_line(const char* name, const StabilityResult& s, bool with_mem) { - char mem[32] = ""; - if (with_mem) std::snprintf(mem, sizeof(mem), " mem=%d MHz", s.avg_mem_mhz); - char line[256]; - std::snprintf(line, sizeof(line), " %s score=%.0f it/s core=%d MHz%s peak=%d C fan=%s power=%d W (%.0f s)", name, s.score, - s.avg_core_mhz, mem, s.peak_temp_c, reading(s.end_fan_pct, " %").c_str(), s.avg_power_w, s.seconds); - return line; -} - -std::string not_applied(const std::string& reason, bool stock_restored) { - return "RESULT: not applied -- " + reason + " (" + (stock_restored ? "card at stock" : "reset to stock FAILED, run `gao --reset`") + ")"; -} - -void add_resets(std::vector& lines, int resets) { - if (resets > 0) lines.push_back(" driver resets during this run: " + std::to_string(resets)); -} - -void add_saved(std::vector& lines, bool saved, const std::string& save_note) { - if (!saved) lines.push_back(" not saved: " + save_note); -} - -} - -std::vector result_lines(const OptimizeOutcome& o) { - if (!o.ran) return {"RESULT: not started -- " + o.error}; - const OptimizeResult& r = o.result; - std::vector lines; - if (!r.ok) { - lines.push_back(not_applied(r.reason, r.stock_restored)); - add_resets(lines, driver_resets(o)); - return lines; - } - char line[256]; - std::snprintf(line, sizeof(line), "RESULT: power %d %%, core +%d MHz (confirmed +%d), mem +%d MHz (confirmed +%d)", r.power_pct, - r.core_mhz, r.core_confirmed, r.mem_mhz, r.mem_confirmed); - lines.push_back(line); - add_resets(lines, driver_resets(o)); - // Both runs last as long, so the temperature and the fan speed compare; at stock the driver had the fans. - lines.push_back(measured_line("before:", o.stock, true)); - lines.push_back(measured_line("after: ", r.soak, true)); - add_saved(lines, o.saved, o.save_note); - return lines; -} - -std::vector result_lines(const UndervoltOutcome& o) { - if (!o.ran) return {"RESULT: not started -- " + o.error}; - const UndervoltResult& r = o.result; - std::vector lines; - if (!r.ok) { - lines.push_back(not_applied(r.reason, r.stock_restored)); - add_resets(lines, driver_resets(o)); - return lines; - } - char line[256]; - std::snprintf(line, sizeof(line), "RESULT: %d MHz at %d mV instead of %d mV (lowest stable %d mV, confirmed %d mV)", r.freq_khz / 1000, - r.applied_uv / 1000, r.stock_uv / 1000, r.edge_uv / 1000, r.confirmed_uv / 1000); - lines.push_back(line); - add_resets(lines, driver_resets(o)); - lines.push_back(measured_line("before:", r.baseline, false)); - lines.push_back(measured_line("after: ", r.after, false)); - add_saved(lines, o.saved, o.save_note); - return lines; -} - -std::vector result_lines(const AllInOneOutcome& o) { - std::vector lines; - if (!o.ok) { - lines.push_back("RESULT: not applied -- " + o.error); - add_resets(lines, driver_resets(o)); - return lines; - } - const Profile& p = o.profile; - char line[256]; - int n = std::snprintf(line, sizeof(line), "RESULT: power %d %%, core +%d MHz, mem +%d MHz", p.power_pct, p.core_mhz, p.mem_mhz); - if (p.undervolt && n > 0 && n < static_cast(sizeof(line))) - n += std::snprintf(line + n, sizeof(line) - n, ", %d MHz at %d mV", p.undervolt->freq_khz / 1000, p.undervolt->volt_uv / 1000); - if (o.fan.ok && n > 0 && n < static_cast(sizeof(line))) - std::snprintf(line + n, sizeof(line) - n, ", fans %d %% at %d C", o.fan.hold_pct, o.fan.hold_temp_c); - lines.push_back(line); - add_resets(lines, driver_resets(o)); - if (!o.undervolt_note.empty()) lines.push_back(" no undervolt: " + o.undervolt_note); - if (!o.fan_note.empty()) lines.push_back(" fans: " + o.fan_note); - if (!o.disturbed.empty()) lines.push_back(" not clean: " + o.disturbed); - lines.push_back(measured_line("stock:", o.stock, true)); - lines.push_back(measured_line("now: ", o.now, true)); - if (o.stock.score > 0 && o.stock.avg_power_w > 0) { - std::snprintf(line, sizeof(line), " that is %+.1f %% score and %+.1f %% power against stock", (o.now.score / o.stock.score - 1) * 100, - (static_cast(o.now.avg_power_w) / o.stock.avg_power_w - 1) * 100); - lines.push_back(line); - } - return lines; -} - -std::vector result_lines(const RetestOutcome& o) { - std::vector lines; - if (o.result.test.seconds > 0) lines.push_back(measured_line(verdict_name(o.result.test.verdict), o.result.test, true)); - if (!o.ok) lines.push_back("RESULT: not applied -- " + o.error); - else - lines.push_back("RESULT: the tune holds on driver " + o.driver + " (it was tested on " + o.driver_was + ") and is applied: " + - profile_text(o.profile)); - return lines; -} - -RetestOutcome run_retest(const OptimizeHooks& hooks) { - RetestOutcome out; - auto fail = [&](const std::string& why) { out.error = why; return out; }; - if (const std::string tool = other_tool(); !tool.empty()) return fail(tool); - if (!is_elevated()) return fail("testing a tune sets clocks and power limits and needs administrator rights"); - const TuningLock lock; - if (!lock.owned()) return fail("another optimize is already running (in the app or on the command line)"); - std::string why; - if (!prepare_state(&why)) return fail(why); - if (const BusyGate busy = busy_before_run(hooks); !busy.refusal.empty()) return fail(busy.refusal); - GuardedGpu hw(kGpu); - if (!hw.Init(&why)) return fail(why); - auto load = std::make_unique(); - if (!load->Init()) return fail("stress init failed: " + load->Error()); - const GpuControl& gpu = hw.control(); - Config cfg = load_config(); - if (!cfg.profile) return fail("there is no saved tune to test"); - out.driver_was = cfg.profile->driver; - out.driver = hw.DriverVersion(); - const std::string gpu_id = hw.GpuUuid(); - const int limit = profile_ceiling(*cfg.profile); - // Every way out: the fans go back to the driver. The app's own fan curve - // driver takes them from there. - struct FansBack { - const GpuControl& gpu; - ~FansBack() { if (gpu.set_fan_auto) gpu.set_fan_auto(); } - } fans_back{gpu}; - const KeepAwake keep_awake; - // The fans as the tune will be used with: the app's curve when it drives - // them, the NVIDIA driver's own otherwise. - std::optional fans; - if (cfg.fan_control && gpu.set_fan_pct) - fans.emplace(gpu, active_fan_curve(cfg).value_or(default_curve(cfg.profile->preset)), limit, - fan_min_for(cfg, gpu_id, gpu.fan_min_pct)); - RetestIo io; - io.probe = [&](double seconds, int max_temp, double stall_below) { - auto read = [&] { - const Telemetry t = gpu.read(); - if (fans) fans->tick(t.temp_c, t.power_w, std::chrono::steady_clock::now(), t.power_limit_w); - return t; - }; - return run_stability([&] { return load->Batch(); }, read, seconds, max_temp, hooks.aborted, stall_below); - }; - io.log = hooks.log; - io.measuring = hooks.measuring; - if (hooks.active_gpu) hooks.active_gpu(&gpu); - out.ran = true; - const bool crashed = !guarded([&] { - out.result = retest_saved_tune(cfg, out.driver, gpu_id, gpu, io); - if (fans) fans->release(); - }); - if (hooks.active_gpu) hooks.active_gpu(nullptr); - auto to_stock = [&](const std::string& reason) { - const bool stock = gpu.reset_to_stock && gpu.reset_to_stock(); - return fail(reason + (stock ? " -- card at stock" : " -- reset to stock FAILED, run `gao --reset`")); - }; - if (crashed) { - if (hooks.measuring) hooks.measuring("", 0); - (void)load.release(); // as in run_optimize: a load that faulted is left alone - return to_stock("access violation while the tune was tested"); - } - if (!out.result.ok) { - if (out.result.test.seconds > 0) - boot_log("the saved tune was tested on driver " + out.driver + " and is not applied: " + out.result.reason); - return fail(out.result.reason + (out.result.at_stock ? " -- card at stock" : " -- reset to stock FAILED, run `gao --reset`")); - } - // Only the driver the tune belongs to, and the strikes, are this test's to change. - Config latest = load_config(); - if (!latest.profile) return to_stock("the saved tune was removed while it was tested"); - latest.profile->driver = out.driver; - latest.boot_strikes = 0; - if (!save_config(latest)) return to_stock("the tune held, but that could not be saved (" + config_path().string() + ")"); - out.ok = true; - out.profile = *latest.profile; - boot_log("tested again on driver " + out.driver + " (it was tested on " + out.driver_was + "): applied " + profile_text(out.profile)); - return out; -} - -AllInOneOutcome run_all_in_one(const OptimizeHooks& outer) { - AllInOneOutcome out; - auto log = [&](const std::string& m) { if (outer.log) outer.log(m); }; - auto stop = [&](const std::string& why) { out.error = why; return out; }; - auto aborted = [&] { return outer.aborted && outer.aborted(); }; - if (const std::string tool = other_tool(); !tool.empty()) return stop(tool); - if (!is_elevated()) return stop("tuning changes clocks, the voltage/frequency curve and the fans and needs administrator rights"); - // Held across the steps (each takes it again, on this thread), so that - // the watchdog does not put a half-made tune back in between. - const TuningLock lock; - if (!lock.owned()) return stop("another optimize is already running (in the app or on the command line)"); - if (const BusyGate busy = busy_before_run(outer); !busy.refusal.empty()) return stop(busy.refusal); - OptimizeHooks hooks = outer; // for the steps: the question about other programs has been asked - hooks.busy_asked = true; - - log("step 1 of 5: the card at stock, five minutes"); - const StockOutcome stock = measure_stock(hooks); - out.stock = stock.result; - if (!stock.error.empty()) return stop(stock.error); - out.ran = true; - - log("step 2 of 5: the overclock"); - out.overclock = run_optimize(Preset::AllInOne, hooks, {}, out.stock); - if (!out.overclock.ran) return stop(out.overclock.error); - if (!out.overclock.result.ok) return stop("the overclock: " + out.overclock.result.reason); - if (!out.overclock.saved) return stop("the overclock could not be saved: " + out.overclock.save_note); - out.now = out.overclock.result.soak; - - log("step 3 of 5: the undervolt on top of it, at a clock between stock and the overclock"); - UndervoltOnTune how; - how.measured = out.overclock.result.soak; - how.save_as = Preset::AllInOne; - const int stock_mhz = out.stock.avg_core_mhz, oc_mhz = out.overclock.result.soak.avg_core_mhz; - if (oc_mhz > stock_mhz && stock_mhz > 0) how.clock_khz = (stock_mhz + oc_mhz) / 2 * 1000; - out.undervolt = run_undervolt(hooks, true, how); - if (aborted()) return stop("aborted"); - if (out.undervolt.ran && out.undervolt.result.ok && out.undervolt.saved) { - out.now = out.undervolt.result.after; - } else { - // The card is at stock now; the saved overclock goes back on in the fan step. - out.undervolt_note = !out.undervolt.ran ? out.undervolt.error - : !out.undervolt.result.ok ? out.undervolt.result.reason - : out.undervolt.save_note; - log(" no undervolt (" + out.undervolt_note + "): the overclock stays as it is"); - } - - log("step 4 of 5: the quietest fan speed that holds the temperature; step 5: the final test"); - const FanStepOutcome fan = fan_step(hooks, out.now.end_fan_pct > 0 ? out.now.end_fan_pct : 70); - if (!fan.error.empty()) return stop(fan.error); - out.fan = fan.fan; - out.fan_note = fan.note; - if (fan.final.seconds > 0 && fan.final.verdict == Verdict::Stable) { - // The same settings ran five minutes just before (the soak of the - // step that found them). A final test that scores clearly less at - // the same clock was not slowed by the tune: another program had - // the card or the processor. On the reference card an emulator - // taking 11 % of the graphics card cost 6 % (hardware check 75). - const StabilityResult before = out.now; - out.now = fan.final; - char line[400]; - if (before.score > 0 && out.now.score < 0.97 * before.score && out.now.avg_core_mhz >= before.avg_core_mhz - 30) { - std::snprintf(line, sizeof(line), - "the final test scored %.0f it/s where the same settings scored %.0f a few minutes earlier, at the same " - "clock: another program was using the graphics card or the processor. Close other programs and " - "run again for numbers that compare", - out.now.score, before.score); - out.disturbed = line; - log(std::string(" note: ") + line); - } - } - Config cfg = load_config(); - if (!cfg.profile) return stop("the saved tune could not be read back"); - // The steps saved their own comparisons; the run's is its first and its last five minutes. - if (const auto against = against_stock(out.stock, out.now)) { - cfg.profile->against_stock = against; - if (!save_config(cfg)) log(" note: the comparison with stock could not be saved (" + config_path().string() + ")"); - } - out.profile = *cfg.profile; - out.ok = true; - return out; -} - } diff --git a/src/app/common_all_in_one.cpp b/src/app/common_all_in_one.cpp new file mode 100644 index 0000000..9acbb08 --- /dev/null +++ b/src/app/common_all_in_one.cpp @@ -0,0 +1,204 @@ +#include "app/common_internal.hpp" +#include "hw/app_files.hpp" +#include "hw/stress.hpp" +#include +#include +#include + +namespace gao::app { + +namespace { + +// The card with the saved tune under load while its fans are tuned: a +// measuring step of the all-in-one run. It brings its own drivers and stress +// load, like the searches and like measure_stock (common_run_steps.cpp). +struct FanStepOutcome { + std::string error; // why the step ended without a result; the card is at stock then + FanTuneResult fan; + StabilityResult final; // the five minutes with everything applied; seconds 0 when not run + std::string note; // what was kept instead, when the tuned curve was not +}; + +// Steps 4 and 5: the saved tune applied, the fans tuned under load, then five +// minutes with the fans on the tuned curve. Saves the curve into the profile +// when that run is stable. A stop request ends at stock. +FanStepOutcome fan_step(const OptimizeHooks& hooks, int start_pct) { + FanStepOutcome out; + auto log = [&](const std::string& m) { if (hooks.log) hooks.log(m); }; + auto fail = [&](const std::string& why) { out.error = why; return out; }; + const TuningLock lock; + if (!lock.owned()) return fail("another optimize is already running (in the app or on the command line)"); + std::string why; + if (!prepare_state(&why)) return fail(why); + GuardedGpu hw(kGpu); + if (!hw.Init(&why)) return fail(why); + auto load = std::make_unique(); + if (!load->Init()) return fail("stress init failed: " + load->Error()); + const GpuControl& gpu = hw.control(); + Config cfg = load_config(); + if (!cfg.profile) return fail("there is no saved tune to tune the fans for"); + const Profile profile = *cfg.profile; + const int limit = profile_ceiling(profile); + const int min_pct = fan_min_for(cfg, hw.GpuUuid(), gpu.fan_min_pct); + // Every way out: the fans go back to the driver. The app's own fan curve + // driver takes them from there. + struct FansBack { + const GpuControl& gpu; + ~FansBack() { if (gpu.set_fan_auto) gpu.set_fan_auto(); } + } fans_back{gpu}; + auto to_stock = [&](const std::string& reason) { + const bool stock = gpu.reset_to_stock && gpu.reset_to_stock(); + return fail(reason + (stock ? " -- card at stock" : " -- reset to stock FAILED, run `gao --reset`")); + }; + if (!apply_profile(gpu, profile, &why)) return fail("the tune could not be applied for the fan tune: " + why); + + const KeepAwake keep_awake; + std::optional fans; // drives the curve under test during the final run + const Probe probe = [&](double seconds, int max_temp, double stall_below) { + auto read = [&] { + const Telemetry t = gpu.read(); + if (fans) fans->tick(t.temp_c, t.power_w, std::chrono::steady_clock::now(), t.power_limit_w); + return t; + }; + return run_stability([&] { return load->Batch(); }, read, seconds, max_temp, hooks.aborted, stall_below); + }; + FanTuneIo io; + io.probe = probe; + io.aborted = hooks.aborted; + io.log = hooks.log; + io.measuring = hooks.measuring; + const bool crashed = !guarded([&] { + out.fan = tune_fan(gpu, io, limit, start_pct, min_pct); + if (hooks.aborted && hooks.aborted()) return; + // The tuned curve, or the profile's own when the tune gave none. + const FanCurve curve = out.fan.ok ? out.fan.curve : active_fan_curve(cfg).value_or(default_curve(profile.preset)); + // The tune left the fans on a speed set by hand. The curve driver + // would take a speed it did not set itself for another program's and + // step aside: it starts from driver control. + if (gpu.set_fan_auto) gpu.set_fan_auto(); + if (gpu.set_fan_pct) fans.emplace(gpu, curve, limit, min_pct); + log(" final test: " + std::to_string(static_cast(kLongRunS)) + " s with everything applied"); + if (hooks.measuring) hooks.measuring("Final test: everything together", kLongRunS); + out.final = probe(kLongRunS, limit, 0.0); + if (hooks.measuring) hooks.measuring("", 0); + if (fans) fans->release(); + fans.reset(); + }); + if (crashed) { + (void)load.release(); + return to_stock("access violation while the fans were tuned"); + } + if ((hooks.aborted && hooks.aborted()) || out.final.verdict == Verdict::Aborted) return to_stock("aborted"); + if (out.final.seconds > 0 && out.final.verdict != Verdict::Stable) { + // With the fans this quiet (or at all) the whole did not hold five + // minutes. The tune passed its own soaks with the fans it had then: + // it stays, the tuned curve does not. After a driver reset the card + // is at stock and the tune is put back. + out.note = std::string("the final test ended ") + verdict_name(out.final.verdict) + "; the tuned fan curve is not kept"; + log(" " + out.note); + out.fan.ok = false; + const bool lost = out.final.verdict == Verdict::DeviceLost || out.final.verdict == Verdict::Stalled || + out.final.verdict == Verdict::NoTelemetry; + if (lost && !(gpu.recover && gpu.recover() && apply_profile(gpu, profile, &why))) + return to_stock("the tune could not be put back after a driver reset in the final test"); + return out; + } + if (!out.fan.ok) { + out.note = "the fans were not tuned (" + out.fan.reason + "); the profile's own curve is kept"; + return out; + } + cfg = load_config(); // only the curve is this step's to change + if (cfg.profile) { + cfg.profile->fan_curve = out.fan.curve; + cfg.fan_curve.reset(); + cfg.fan_control = true; + if (!save_config(cfg)) out.note = "the tuned fan curve could not be saved"; + } + return out; +} + +} + +AllInOneOutcome run_all_in_one(const OptimizeHooks& outer) { + AllInOneOutcome out; + auto log = [&](const std::string& m) { if (outer.log) outer.log(m); }; + auto stop = [&](const std::string& why) { out.error = why; return out; }; + auto aborted = [&] { return outer.aborted && outer.aborted(); }; + if (const std::string tool = other_tool(); !tool.empty()) return stop(tool); + if (!is_elevated()) return stop("tuning changes clocks, the voltage/frequency curve and the fans and needs administrator rights"); + // Held across the steps (each takes it again, on this thread), so that + // the watchdog does not put a half-made tune back in between. + const TuningLock lock; + if (!lock.owned()) return stop("another optimize is already running (in the app or on the command line)"); + if (const BusyGate busy = busy_before_run(outer); !busy.refusal.empty()) return stop(busy.refusal); + OptimizeHooks hooks = outer; // for the steps: the question about other programs has been asked + hooks.busy_asked = true; + + log("step 1 of 5: the card at stock, five minutes"); + const StockOutcome stock = measure_stock(hooks); + out.stock = stock.result; + if (!stock.error.empty()) return stop(stock.error); + out.ran = true; + + log("step 2 of 5: the overclock"); + out.overclock = run_optimize(Preset::AllInOne, hooks, {}, out.stock); + if (!out.overclock.ran) return stop(out.overclock.error); + if (!out.overclock.result.ok) return stop("the overclock: " + out.overclock.result.reason); + if (!out.overclock.saved) return stop("the overclock could not be saved: " + out.overclock.save_note); + out.now = out.overclock.result.soak; + + log("step 3 of 5: the undervolt on top of it, at a clock between stock and the overclock"); + UndervoltOnTune how; + how.measured = out.overclock.result.soak; + how.save_as = Preset::AllInOne; + const int stock_mhz = out.stock.avg_core_mhz, oc_mhz = out.overclock.result.soak.avg_core_mhz; + if (oc_mhz > stock_mhz && stock_mhz > 0) how.clock_khz = (stock_mhz + oc_mhz) / 2 * 1000; + out.undervolt = run_undervolt(hooks, true, how); + if (aborted()) return stop("aborted"); + if (out.undervolt.ran && out.undervolt.result.ok && out.undervolt.saved) { + out.now = out.undervolt.result.after; + } else { + // The card is at stock now; the saved overclock goes back on in the fan step. + out.undervolt_note = !out.undervolt.ran ? out.undervolt.error + : !out.undervolt.result.ok ? out.undervolt.result.reason + : out.undervolt.save_note; + log(" no undervolt (" + out.undervolt_note + "): the overclock stays as it is"); + } + + log("step 4 of 5: the quietest fan speed that holds the temperature; step 5: the final test"); + const FanStepOutcome fan = fan_step(hooks, out.now.end_fan_pct > 0 ? out.now.end_fan_pct : 70); + if (!fan.error.empty()) return stop(fan.error); + out.fan = fan.fan; + out.fan_note = fan.note; + if (fan.final.seconds > 0 && fan.final.verdict == Verdict::Stable) { + // The same settings ran five minutes just before (the soak of the + // step that found them). A final test that scores clearly less at + // the same clock was not slowed by the tune: another program had + // the card or the processor. On the reference card an emulator + // taking 11 % of the graphics card cost 6 % (hardware check 75). + const StabilityResult before = out.now; + out.now = fan.final; + char line[400]; + if (before.score > 0 && out.now.score < 0.97 * before.score && out.now.avg_core_mhz >= before.avg_core_mhz - 30) { + std::snprintf(line, sizeof(line), + "the final test scored %.0f it/s where the same settings scored %.0f a few minutes earlier, at the same " + "clock: another program was using the graphics card or the processor. Close other programs and " + "run again for numbers that compare", + out.now.score, before.score); + out.disturbed = line; + log(std::string(" note: ") + line); + } + } + Config cfg = load_config(); + if (!cfg.profile) return stop("the saved tune could not be read back"); + // The steps saved their own comparisons; the run's is its first and its last five minutes. + if (const auto against = against_stock(out.stock, out.now)) { + cfg.profile->against_stock = against; + if (!save_config(cfg)) log(" note: the comparison with stock could not be saved (" + config_path().string() + ")"); + } + out.profile = *cfg.profile; + out.ok = true; + return out; +} + +} diff --git a/src/app/common_boot.cpp b/src/app/common_boot.cpp new file mode 100644 index 0000000..90a3ac0 --- /dev/null +++ b/src/app/common_boot.cpp @@ -0,0 +1,215 @@ +#include "app/common.hpp" +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include "core/task_xml.hpp" +#include "core/version.hpp" +#include "hw/app_files.hpp" +#include "hw/boot_task.hpp" +#include "hw/gpu_control.hpp" +#include "hw/nvapi.hpp" +#include "hw/nvml.hpp" +#include "hw/update_io.hpp" +#include +#include + +namespace gao::app { + +BootApplyOutcome apply_at_logon() { + BootApplyOutcome out; + auto done = [&](const std::string& msg) { out.message = msg; boot_log(msg); return out; }; + Nvml nvml; + if (!nvml.Init()) return done("NVML init failed: " + nvml.Error()); + Config cfg = load_config(); + const std::string driver = nvml.DriverVersion(); + const auto d = decide_boot(cfg, driver, nvml.GpuUuid(kGpu)); + out.decision = d; + if (d != BootDecision::Apply) return done("not applied: " + decision_text(d, cfg, driver)); + // The strike is on disk before the hardware is touched: a crash from here + // on counts. + ++cfg.boot_strikes; + if (!save_config(cfg)) return done("could not record the strike; not applying"); + // A strike is for a logon that may have crashed the machine. An apply + // that ended here, with nothing of the profile on the card, did not: the + // strike is taken back, or three refused logons would read as "disabled + // after 3 crashes". + auto no_strike = [&] { + --cfg.boot_strikes; + if (!save_config(cfg)) boot_log("could not take the strike back; it stays"); + }; + Nvapi nvapi; + if (!nvapi.Init()) { no_strike(); return done("NVAPI init failed: " + nvapi.Error()); } + std::string why; + bool left_clean = false; + if (!apply_profile(make_gpu_control(nvml, nvapi, kGpu), *cfg.profile, &why, &left_clean)) { + if (left_clean) no_strike(); + return done("apply failed: " + why); + } + out.applied = true; + out.profile = *cfg.profile; + return done("applied " + profile_text(*cfg.profile) + ", strike " + std::to_string(cfg.boot_strikes) + + " clears in 2 minutes"); +} + +void clear_boot_strike() { + // Reload: an optimize may have saved a new profile meanwhile; only the + // counter is ours to change. If the file cannot be read, or reads without + // a profile, writing would destroy it: leave the strike (fails safe). + for (int attempt = 0; attempt < 3; ++attempt) { + const auto text = read_file(config_path()); + Config latest = text ? from_json(*text) : Config{}; + if (!latest.profile) { boot_log("could not re-read gao.json to clear the strike; it stays"); return; } + latest.boot_strikes = 0; + if (save_config(latest)) { boot_log("ran 2 minutes without a crash; strike cleared"); return; } + Sleep(1000); + } + boot_log("could not save gao.json to clear the strike; it stays"); +} + +bool enable_boot(std::string* message) { + auto say = [&](const std::string& m, bool ok) { if (message) *message = m; return ok; }; + if (!is_elevated()) return say("boot-apply needs administrator rights", false); + std::string why; + if (!prepare_state(&why)) return say(why, false); + const std::string sid = current_user_sid(); + if (sid.empty()) return say("could not determine the current user's SID", false); + wchar_t self[MAX_PATH]; + const DWORD n = GetModuleFileNameW(nullptr, self, MAX_PATH); + if (n == 0 || n >= MAX_PATH) return say("could not find this program's own path", false); + if (!install_app(std::filesystem::path(self).parent_path(), &why)) + return say(why, false); + // The logon task starts the tray app, which applies the tune and stays + // resident to keep it applied: no time limit. + const auto exe = installed_tray_path(); + const auto u8 = exe.u8string(); + const std::string xml = boot_task_xml(std::string(u8.begin(), u8.end()), "--tray", sid, "PT0S"); + if (const long code = boot_task_create(xml); code < 0) { + char hex[16]; + std::snprintf(hex, sizeof(hex), "%08lX", static_cast(code)); + return say(std::string("could not create the task (Task Scheduler error 0x") + hex + ")", false); + } + // Not a reason to fail: the task is there, and the tray icon still opens the app. + const bool in_start_menu = start_menu_entry_create() >= 0; + Config cfg = load_config(); + cfg.boot_strikes = 0; + if (!save_config(cfg)) return say("task created, but could not reset the strike counter", false); + return say("boot-apply on: " + exe.string() + " --tray runs at every logon and keeps the tune applied (strikes reset)" + + (in_start_menu ? "" : "; the Start menu entry could not be made") + + (cfg.profile ? "" : "; note: there is no saved profile yet, optimize first"), true); +} + +bool disable_boot(std::string* message) { + auto say = [&](const std::string& m, bool ok) { if (message) *message = m; return ok; }; + if (!is_elevated()) return say("boot-apply needs administrator rights", false); + std::string why; + if (!prepare_state(&why)) return say(why, false); + const bool task_removed = boot_task_remove() >= 0; + start_menu_entry_remove(); + const bool removed_now = uninstall_app(); + return say(std::string("boot-apply off: task ") + (task_removed ? "removed" : "not removed") + ", installed copy " + + (removed_now ? "removed" : "in use by the running tray app; it is removed at the next restart"), + task_removed); +} + +namespace { +std::filesystem::path own_dir() { + wchar_t self[MAX_PATH]; + const DWORD n = GetModuleFileNameW(nullptr, self, MAX_PATH); + return n == 0 || n >= MAX_PATH ? std::filesystem::path() : std::filesystem::path(self).parent_path(); +} +} + +bool logon_copy_is_this_build() { + const auto dir = own_dir(); + if (dir.empty()) return false; + return files_equal(dir / L"gao.exe", installed_exe_path()) && files_equal(dir / L"GpuAutoOptimizer.exe", installed_tray_path()); +} + +bool logon_copy_outdated() { + if (!boot_task_exists()) return false; + const auto dir = own_dir(); + std::error_code ec; + if (dir.empty() || std::filesystem::equivalent(dir, installed_dir(), ec)) return false; + if (!std::filesystem::exists(installed_exe_path(), ec) || logon_copy_is_this_build()) return false; + // Never a downgrade: an old zip that is still lying around must not + // replace a newer installed copy just because it was started. + const auto installed = file_version_number(installed_exe_path()); + return !(installed && *installed > kVersionNumber); +} + +void ensure_start_menu_entry() { + std::error_code ec; + if (is_elevated() && boot_task_exists() && !start_menu_entry_exists() && std::filesystem::exists(installed_tray_path(), ec)) + start_menu_entry_create(); +} + +std::string update_logon_copy() { + if (!is_elevated() || !logon_copy_outdated()) return {}; + std::string message; + if (!enable_boot(&message)) return "The copy that starts at logon could not be updated: " + message; + return "The copy that starts at logon was updated to this version (" + std::string(kVersion) + ")."; +} + +UpdateCheck check_for_update() { + UpdateCheck out; + const auto body = https_get("https://api.github.com/repos/Rovey/gpu-auto-optimizer/releases/latest", 1024 * 1024, &out.error); + if (!body) return out; + auto release = parse_latest_release(*body); + if (!release) { out.error = "the answer did not describe a release"; return out; } + if (*version_number(release->version) > kVersionNumber) out.release = std::move(release); + return out; +} + +bool own_folder_writable() { + const auto dir = own_dir(); + if (dir.empty()) return false; + const auto probe = dir / (L"write-test-" + std::to_wstring(GetCurrentProcessId()) + L".tmp"); + const HANDLE file = CreateFileW(probe.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, + FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE, nullptr); + if (file == INVALID_HANDLE_VALUE) return false; + CloseHandle(file); + return true; +} + +bool install_update(const ReleaseInfo& release, std::string* message) { + auto say = [&](const std::string& m, bool ok) { if (message) *message = m; return ok; }; + const auto dir = own_dir(); + if (dir.empty()) return say("could not find this program's own folder", false); + // Elevated, the download is unpacked where only administrators can write: + // between the hash check and the copy, nobody else may swap the files. + std::filesystem::path work; + std::string why; + if (is_elevated()) { + if (!prepare_state(&why)) return say(why, false); + work = app_dir() / L"update"; + } else { + std::error_code ec; + work = std::filesystem::temp_directory_path(ec) / L"GpuAutoOptimizer-update"; + if (ec) return say("no temporary folder", false); + } + std::error_code ec; + std::filesystem::remove_all(work, ec); + const auto unpacked = work / L"files"; + if (!std::filesystem::create_directories(unpacked, ec)) return say("could not create " + work.string(), false); + struct Cleanup { + std::filesystem::path p; + ~Cleanup() { std::error_code e; std::filesystem::remove_all(p, e); } + } cleanup{work}; + + // What to do and in which order is install_release's (core, tested with a + // fake source); here are only the real steps. + const auto zip = work / L"update.zip"; + UpdateSteps steps; + steps.download = [&](const std::string& url, std::string* error) { return https_download(url, zip, 64 * 1024 * 1024, error); }; + steps.sha256 = [&] { return sha256_hex(zip); }; + steps.unpack = [&](std::string* error) { return extract_zip(zip, unpacked, error); }; + steps.unpacked_is = [&](unsigned version) { + for (const wchar_t* name : kAppExes) + if (file_version_number(unpacked / name) != version) return false; + return true; + }; + steps.replace = [&](std::string* error) { return copy_app(unpacked, dir, error); }; + return install_release(release, steps, message); +} + +} diff --git a/src/app/common_internal.hpp b/src/app/common_internal.hpp new file mode 100644 index 0000000..c7c8b76 --- /dev/null +++ b/src/app/common_internal.hpp @@ -0,0 +1,69 @@ +#pragma once +// What the run files (common_*.cpp) share among themselves; not part of +// the interface of common.hpp. +#include "app/common.hpp" +#include "app/guarded_gpu.hpp" +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include "core/fan_curve.hpp" +#include "core/stability.hpp" +#include +#include + +namespace gao::app { + +struct KeepAwake { + KeepAwake() { SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED); } + ~KeepAwake() { SetThreadExecutionState(ES_CONTINUOUS); } +}; + +constexpr double kLongRunS = 300; // as the soaks: the two sides of the comparison last equally long + +// How long a look at what else uses the card lasts, in samples a second apart. +constexpr int kBusyLookS = 3; + +MeasuredRun measured_run(const StabilityResult& r); +std::optional against_stock(const StabilityResult& stock, const StabilityResult& tuned); +std::string other_tool(); +BusyGate busy_before_run(const OptimizeHooks& hooks); + +// The card at stock under load, five minutes: the "before" of every result. +// It brings its own drivers and stress load, like the searches, and leaves the +// fans to the NVIDIA driver. +struct StockOutcome { + std::string error; // why no run follows: it did not run, was stopped, or the card failed at stock + StabilityResult result; +}; + +StockOutcome measure_stock(const OptimizeHooks& hooks); +Telemetry read_with_fans(const GpuControl& gpu, FanDriver& fans, const OptimizeHooks& hooks); + +// The fans of a run back to the driver, on every exit: done, aborted, failed +// or thrown. +struct FanRelease { + FanDriver& f; + const GuardedGpu& hw; + const GpuControl& gpu; + const OptimizeHooks& hooks; + ~FanRelease() { + f.release(); + // A hand-back that did not reach the driver, from a fan driver + // that failed earlier or from the release() above, is remembered + // by `hw`; the fans may still be at a manual speed. One more + // attempt, and only then: a hand-back that was delivered is not + // repeated, it would take the fans from another program that set + // them since. Hand back only, never a speed. + if (hw.FanAutoOwed() && gpu.set_fan_auto) gpu.set_fan_auto(); + if (!hw.FanAutoOwed()) return; + // No reconnect at exit, so nothing will deliver it any more. A + // destructor must not throw; the line is lost if it cannot be built. + try { + if (hooks.log) + hooks.log("fans: could not be handed back to the driver; they may still be at a manual speed -- run `gao --fan auto`"); + } catch (...) { + } + } +}; + +} diff --git a/src/app/common_optimize.cpp b/src/app/common_optimize.cpp new file mode 100644 index 0000000..bceadb2 --- /dev/null +++ b/src/app/common_optimize.cpp @@ -0,0 +1,406 @@ +#include "app/common_internal.hpp" +#include "core/journal.hpp" +#include "hw/app_files.hpp" +#include "hw/gpu_users.hpp" +#include "hw/stress.hpp" +#include +#include +#include + +namespace gao::app { + +OptimizeOutcome run_optimize(Preset preset, const OptimizeHooks& hooks, const std::optional& fan_curve, + const std::optional& known_stock) { + OptimizeOutcome out; + auto log = [&](const std::string& m) { if (hooks.log) hooks.log(m); }; + auto fail = [&](const std::string& why) { out.error = why; return out; }; + if (preset == Preset::Undervolt) return fail("the undervolt is not a clock search; it has its own run"); + if (const std::string tool = other_tool(); !tool.empty()) return fail(tool); + if (!is_elevated()) return fail("optimizing changes clocks and power limits and needs administrator rights"); + const TuningLock lock; + if (!lock.owned()) return fail("another optimize is already running (in the app or on the command line)"); + std::string why; + if (!prepare_state(&why)) return fail(why); + const BusyGate busy = busy_before_run(hooks); + if (!busy.refusal.empty()) return fail(busy.refusal); + // Before the search and with drivers of its own, so that only one stress + // load exists at a time. The lock is taken again there, on this thread. + if (known_stock) { + out.stock = *known_stock; + } else { + log("the card at stock, five minutes"); + const StockOutcome measured = measure_stock(hooks); + if (!measured.error.empty()) return fail(measured.error); + out.stock = measured.result; + } + // Every driver call of the run goes through `hw`: guarded against a fault + // inside the driver DLLs, and reconnected by gpu.recover after a driver + // reset. `gpu` is never reassigned, so the references the fan driver, the + // search and the emergency handlers hold stay valid across a reconnect. + GuardedGpu hw(kGpu); + if (!hw.Init(&why)) return fail(why); + // On the heap, so the path that follows an access violation during the + // search can leave it alone instead of destroying it (see there). + auto load = std::make_unique(); + if (!load->Init()) return fail("stress init failed: " + load->Error()); + const GpuControl& gpu = hw.control(); + // The profile's curve drives the fans for the whole run, so the clocks it + // finds hold at the temperatures that curve produces. + const FanCurve curve = fan_curve.value_or(default_curve(preset)); + FanDriver fans(gpu, curve, objectives_for(preset).max_temp_c, fan_min_for(load_config(), hw.GpuUuid(), gpu.fan_min_pct)); + const FanRelease fan_release{fans, hw, gpu, hooks}; + if (!gpu.set_fan_pct) log("fans: not controllable on this card; the driver keeps them"); + + const auto path = journal_path(); + if (path.empty()) return fail("the ProgramData folder could not be resolved; cannot keep the crash journal"); + const auto lines = read_lines(path); + if (!lines) return fail("the crash journal " + path.string() + " exists but cannot be read; not tuning without it"); + // What a memory edge in the journal is bound to: this card with this driver. + const std::string rig_card = hw.GpuUuid(), rig_driver = hw.DriverVersion(); + Journal journal(*lines, [&path](const std::string& l) { return append_line_durable(path, l); }, + rig_card.empty() || rig_driver.empty() ? std::string() : rig_card + " / " + rig_driver); + // Prove the journal is writable before any clock is touched; the parser + // ignores lines without an id, so this one never becomes a ceiling. + if (!append_line_durable(path, "{\"session\":\"" + now_text() + "\"}")) + return fail("cannot write the crash journal " + path.string() + "; not tuning without it"); + for (const auto& f : journal.freeze_entries()) + log("warning: a previous run froze the machine at " + f.description + + (f.caps_anything ? "; staying below it from now on" : "; there is no setting to stay below")); + if (!gpu.set_power_limit) log("power limit: not adjustable on this card, skipped"); + + // A run lasts minutes with a candidate applied and a journal entry open; + // idle sleep must not interrupt it. The display may still turn off. + struct KeepAwake { + KeepAwake() { SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED); } + ~KeepAwake() { SetThreadExecutionState(ES_CONTINUOUS); } // every exit + } keep_awake; + OptimizeIo io; + io.probe = [&](double seconds, int max_temp, double stall_below) { + return run_stability([&] { return load->Batch(); }, [&] { return read_with_fans(gpu, fans, hooks); }, seconds, max_temp, + hooks.aborted, stall_below); + }; + io.aborted = hooks.aborted; + io.log = hooks.log; + io.bandwidth = [&] { + const auto gbps = load->MeasureBandwidth(); + if (!gbps) log("bandwidth measurement failed: " + load->Error()); + return gbps; + }; + io.bandwidth_unsettled = [&] { return load->BandwidthUnsettled(); }; + // The wait between a driver reset and the next load. The card is left + // alone: no load, no driver call, no fan tick, and no log line (the search + // says that it rests). Short slices, so a stop request ends the wait + // within a quarter of a second. + io.rest = [&hooks](double seconds) { + const auto stop_requested = [&hooks] { return hooks.aborted && hooks.aborted(); }; + if (stop_requested()) return false; + if (!(seconds > 0)) return true; // nothing to wait for + using namespace std::chrono; + const auto end = steady_clock::now() + duration_cast(duration(std::min(seconds, 3600.0))); + for (;;) { + const auto left_ms = duration_cast(end - steady_clock::now()).count(); + if (left_ms <= 0) return true; + Sleep(static_cast(left_ms < 250 ? left_ms : 250)); + if (stop_requested()) return false; + } + }; + // After a driver reset the load may hold a removed device without having + // noticed. A fresh one is built here, outside any probe, so the health + // probe times the card and not the load's start-up. + // Not guarded here: a fault inside D3D reaches the guard around the + // search, like every other call of the load, and the run ends through the + // crashed path, which never touches the load again. + io.prepare_load = [&] { + if (load->Recreate()) return true; + 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 + // journal entry open. The frames of the search are skipped, not unwound; + // everything that must run on the way out lives in this frame. A C++ + // exception passes through the guard to the catch below. + bool crashed = false; + try { + crashed = !guarded([&] { out.result = optimize(gpu, objectives_for(preset), journal, io, out.stock); }); + } catch (...) { // never leave a candidate applied, whatever went wrong + if (hooks.active_gpu) hooks.active_gpu(nullptr); + if (gpu.reset_to_stock) gpu.reset_to_stock(); + // The machine did not freeze: an entry left open would become a + // ceiling the next run stays below. + if (journal.open_id() >= 0) journal.complete(journal.open_id(), "CRASHED"); + throw; + } + if (crashed) { + // An access violation somewhere in the search: in a driver DLL, in the + // stress load's D3D calls or in our own code. A candidate may still be + // applied and its journal entry open. + // + // Leaked on purpose, and before anything that can throw, so that no + // exception from the steps below destroys it on the way out. The + // fault skipped the frames of the search, so a D3D call of the stress + // load may have been abandoned halfway; its destructor would release + // COM objects into that same user-mode driver and could fault outside + // any guard, or hang. `gao` is about to report the failure and exit, + // and the device goes with the process. The window application lives + // on: there the leaked load, with its device and buffers, stays until + // the application exits. + (void)load.release(); + bool stock = false; + { + // The emergency handler stays registered until the card and the + // journal are dealt with: the steps below can take half a minute, + // and without it Ctrl+C would end the process and a closed window + // would reset nothing. Unregistered when this block ends, also by + // an exception, and always before `hw` is destroyed. + struct Unregister { + const OptimizeHooks& h; + ~Unregister() { if (h.active_gpu) h.active_gpu(nullptr); } + } unregister{hooks}; + log("access violation during the search -- resetting the card to stock"); + // The machine did not freeze, so the candidate must not become a + // ceiling. Closing the entry is a file append, no driver call, so + // it comes first: nothing that happens during the reset can leave + // it open. + if (journal.open_id() >= 0 && !journal.complete(journal.open_id(), "CRASHED")) + log("warning: the crash journal entry could not be closed; later runs will stay below this candidate"); + // The connection is often still good (the fault may have been + // outside NVML and NVAPI), so reset first; reconnecting costs + // seconds with the candidate applied. + stock = gpu.reset_to_stock && gpu.reset_to_stock(); + if (!stock && gpu.reset_to_stock && gpu.recover) { + log("the reset failed; reconnecting to the driver, up to 30 s"); + stock = gpu.recover() && gpu.reset_to_stock(); + } + log(std::string("the search ended in an access violation -- ") + + (stock ? "card restored to stock" : "reset FAILED, run `gao --reset`")); + } + out.ran = true; + // optimize never returned, so out.result was never assigned: every + // field not set here keeps its default. driver_resets reads 0, + // whatever the search had counted before the fault. + out.result.ok = false; + out.result.stock_restored = stock; + out.result.reason = "access violation during the search"; + return out; + } + if (hooks.active_gpu) hooks.active_gpu(nullptr); + out.ran = true; + // A second look, now that the load is off the card: a program that + // started or stopped using it during the run moved the numbers by several + // percent, and the measurement at stock was taken without that. + if (!hooks.busy_asked && !(hooks.aborted && hooks.aborted())) { + out.busy_note = busy_change(busy.programs, busy_programs(gpu_use(kBusyLookS))); + if (!out.busy_note.empty()) log("note: " + out.busy_note + "; its numbers and the ones at stock were not measured on the same card"); + } + if (!out.result.ok) return out; + + const std::string driver = hw.DriverVersion(), gpu_id = hw.GpuUuid(); + if (driver.empty() || gpu_id.empty()) { + out.save_note = "NVML did not report the driver version or GPU id, so the profile could never be re-applied"; + return out; + } + Config cfg = load_config(); + cfg.profile = Profile{preset, out.result.power_pct, out.result.core_mhz, out.result.mem_mhz, driver, gpu_id, now_text()}; + cfg.profile->against_stock = against_stock(out.stock, out.result.soak); + cfg.fan_curve.reset(); // a new tune starts from its own curve + // Only a curve that actually drove the fans counts as tested. + if (gpu.set_fan_pct && fans.state().mode != FanMode::Failed && fans.state().mode != FanMode::Foreign) { + cfg.profile->fan_curve = curve; // what the run was tested with + cfg.fan_control = true; + if (fans.state().min_pct > fan_min_for(cfg, gpu_id, gpu.fan_min_pct)) { // learned during the run + cfg.fan_min_pct = fans.state().min_pct; + cfg.fan_min_gpu = gpu_id; + } + } + cfg.boot_strikes = 0; // strikes belong to the profile they were earned by + out.saved = save_config(cfg); + if (!out.saved) out.save_note = "could not write " + config_path().string(); + return out; +} + +UndervoltOutcome run_undervolt(const OptimizeHooks& hooks, bool on_saved_tune, const UndervoltOnTune& how) { + UndervoltOutcome out; + auto log = [&](const std::string& m) { if (hooks.log) hooks.log(m); }; + auto fail = [&](const std::string& why) { out.error = why; return out; }; + if (const std::string tool = other_tool(); !tool.empty()) return fail(tool); + if (!is_elevated()) return fail("undervolting changes the card's voltage/frequency curve and needs administrator rights"); + const TuningLock lock; + if (!lock.owned()) return fail("another optimize is already running (in the app or on the command line)"); + std::string why; + if (!prepare_state(&why)) return fail(why); + if (const BusyGate busy = busy_before_run(hooks); !busy.refusal.empty()) return fail(busy.refusal); + // The card at stock first, as every run measures it: the "before" of the + // result, with the fans left to the driver, stopped at kStockTempC and + // kept when it was, and followed by a cool-down (measure_stock). With + // drivers of its own, before this run makes its own, as in run_optimize. + // Not on top of the saved tune: the reference there is that overclock. + std::optional stock_run; + if (!on_saved_tune) { + log("the card at stock, five minutes"); + const StockOutcome measured = measure_stock(hooks); + if (!measured.error.empty()) return fail(measured.error); + stock_run = measured.result; + } + GuardedGpu hw(kGpu); // guarded driver calls and a reconnect, as in run_optimize + if (!hw.Init(&why)) return fail(why); + auto load = std::make_unique(); // on the heap: left alone after an access violation + if (!load->Init()) return fail("stress init failed: " + load->Error()); + const GpuControl& gpu = hw.control(); + // The fans follow a curve for the whole search, as in run_optimize: the + // one in use (the overclock's own, in an all-in-one run), or the preset's. + // Left to the driver, some cards get hotter under this load than the + // search allows (a 3080 Ti reached 85 C at stock with its fans at 65 %), + // and every probe would end TOO HOT. + const Objectives obj = objectives_for(Preset::Undervolt); + const Config at_start = load_config(); + // On a card that reached more than the profile's limit in the reference + // the search runs with a higher one (temp_ceiling), and so do the fans: + // with "100 % from 75 C" they would hold a card that reaches 86 C by + // itself at 74 C and be far louder than at stock (93 % against 66 % on + // the tester's RTX 3080 Ti). The curve moves up with the limit, by as + // much as the limit is above the one the curve was drawn against: a curve + // saved by a run on this card was drawn against that run's ceiling. + const std::optional& reference = on_saved_tune ? how.measured : stock_run; + const int ceiling = reference ? temp_ceiling(obj.max_temp_c, reference->peak_temp_c, gpu.temp_target_c) : obj.max_temp_c; + const int drawn_against = at_start.profile ? profile_ceiling(*at_start.profile) : obj.max_temp_c; + const FanCurve fan_curve = + shifted_curve(active_fan_curve(at_start).value_or(default_curve(Preset::Undervolt)), ceiling - drawn_against); + if (ceiling > obj.max_temp_c) + log("fans: on this card the limit is " + std::to_string(ceiling) + " C, not the profile's " + std::to_string(obj.max_temp_c) + + " C" + (ceiling > drawn_against ? "; the fan curve moves up with it by " + std::to_string(ceiling - drawn_against) + " C" + : std::string("; the fan curve was drawn for it"))); + FanDriver fans(gpu, fan_curve, ceiling, fan_min_for(at_start, hw.GpuUuid(), gpu.fan_min_pct)); + const FanRelease fan_release{fans, hw, gpu, hooks}; + if (!gpu.set_fan_pct) log("fans: not controllable on this card; the driver keeps them"); + + const auto path = journal_path(); + if (path.empty()) return fail("the ProgramData folder could not be resolved; cannot keep the crash journal"); + const auto lines = read_lines(path); + if (!lines) return fail("the crash journal " + path.string() + " exists but cannot be read; not tuning without it"); + Journal journal(*lines, [&path](const std::string& l) { return append_line_durable(path, l); }); + if (!append_line_durable(path, "{\"session\":\"" + now_text() + "\"}")) + return fail("cannot write the crash journal " + path.string() + "; not tuning without it"); + + struct KeepAwake { + KeepAwake() { SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED); } + ~KeepAwake() { SetThreadExecutionState(ES_CONTINUOUS); } + } keep_awake; + UndervoltIo io; + io.probe = [&](double seconds, int max_temp, double stall_below) { + return run_stability([&] { return load->Batch(); }, [&] { return read_with_fans(gpu, fans, hooks); }, seconds, max_temp, + hooks.aborted, stall_below); + }; + io.aborted = hooks.aborted; + io.log = hooks.log; + io.measuring = hooks.measuring; + io.rest = [&hooks](double seconds) { // no load, no driver call; a stop request ends it within a quarter of a second + using namespace std::chrono; + const auto end = steady_clock::now() + duration_cast(duration(std::min(seconds, 3600.0))); + for (;;) { + if (hooks.aborted && hooks.aborted()) return false; + const auto left_ms = duration_cast(end - steady_clock::now()).count(); + if (left_ms <= 0) return true; + Sleep(static_cast(left_ms < 250 ? left_ms : 250)); + } + }; + io.prepare_load = [&] { + if (load->Recreate()) return true; + log("the stress load could not be rebuilt: " + load->Error()); + return false; + }; + + // The overclock the undervolt goes on top of, when asked for: the saved + // tune, if it is this card's and this driver's (strikes only gate the + // logon apply). + UndervoltBase base; + base.measured = stock_run; + std::optional over; + if (on_saved_tune) { + Config cfg = load_config(); + cfg.boot_strikes = 0; + const std::string driver_now = hw.DriverVersion(); + const BootDecision d = decide_boot(cfg, driver_now, hw.GpuUuid()); + if (d != BootDecision::Apply) return fail("no saved tune to undervolt: " + decision_text(d, cfg, driver_now)); + over = cfg.profile; + base = UndervoltBase{over->power_pct, over->core_mhz, over->mem_mhz, how.measured, how.clock_khz}; + log("on top of the saved tune: power " + std::to_string(base.power_pct) + " %, core +" + std::to_string(base.core_mhz) + + " MHz, mem +" + std::to_string(base.mem_mhz) + " MHz"); + } + if (hooks.active_gpu) hooks.active_gpu(&gpu); + bool crashed = false; + try { + crashed = !guarded([&] { out.result = find_undervolt(gpu, journal, io, obj.max_temp_c, obj.perf_push, base); }); + } catch (...) { // never leave a candidate applied, whatever went wrong + if (hooks.active_gpu) hooks.active_gpu(nullptr); + if (gpu.reset_to_stock) gpu.reset_to_stock(); + if (journal.open_id() >= 0) journal.complete(journal.open_id(), "CRASHED"); + throw; + } + if (crashed) { + // As in run_optimize: the frames of the search were skipped, the load + // may be halfway through a D3D call and is left alone. + (void)load.release(); + log("access violation during the search -- resetting the card to stock"); + if (journal.open_id() >= 0 && !journal.complete(journal.open_id(), "CRASHED")) + log("warning: the crash journal entry could not be closed; later runs will stay below this candidate"); + bool stock = gpu.reset_to_stock && gpu.reset_to_stock(); + if (!stock && gpu.reset_to_stock && gpu.recover) stock = gpu.recover() && gpu.reset_to_stock(); + out.result = {}; + out.result.reason = "access violation during the search"; + out.result.stock_restored = stock; + } + if (hooks.active_gpu) hooks.active_gpu(nullptr); + out.ran = true; + if (!out.result.ok) return out; + + const std::string driver = hw.DriverVersion(), gpu_id = hw.GpuUuid(); + if (driver.empty() || gpu_id.empty()) { + out.save_note = "NVML did not report the driver version or GPU id, so the profile could never be re-applied"; + return out; + } + Config cfg = load_config(); + Profile p{Preset::Undervolt, 100, 0, 0, driver, gpu_id, now_text()}; + // The curve the fans followed before stays theirs: it is the one the + // search ran with. + if (cfg.profile) p.fan_curve = cfg.profile->fan_curve; + if (over) { // the overclock it was searched on keeps everything it had; the undervolt joins it + p = *over; + p.saved_at = now_text(); + if (how.save_as) p.preset = *how.save_as; + } + // The limit the tune was found with goes with it: the fans, the Fan page + // and a later test of the tune use it (profile_ceiling). + if (ceiling > objectives_for(p.preset).max_temp_c) p.temp_ceiling_c = ceiling; + else p.temp_ceiling_c.reset(); + // Only a curve that actually drove the fans counts as tested. + if (gpu.set_fan_pct && fans.state().mode != FanMode::Failed && fans.state().mode != FanMode::Foreign) { + if (ceiling > drawn_against) { + // The curve moved for this run: it is the one that was tested, and + // the one drawn against the limit that is saved with it. An edited + // curve went into it and does not stay next to it. + p.fan_curve = fan_curve; + cfg.fan_curve.reset(); + } + if (!p.fan_curve) p.fan_curve = fan_curve; + cfg.fan_control = true; + } + p.undervolt = UndervoltTune{out.result.applied_uv, out.result.freq_khz, out.result.raise_khz}; + // On a saved tune the baseline is that tune, not stock: the stock run it was saved with stays. + if (!over) p.against_stock = against_stock(out.result.baseline, out.result.after); + else if (p.against_stock && out.result.after.score > 0) p.against_stock->tuned = measured_run(out.result.after); + cfg.profile = p; + cfg.boot_strikes = 0; // strikes belong to the profile they were earned by + out.saved = save_config(cfg); + if (!out.saved) out.save_note = "could not write " + config_path().string(); + return out; +} + +} diff --git a/src/app/common_results.cpp b/src/app/common_results.cpp new file mode 100644 index 0000000..14a4f44 --- /dev/null +++ b/src/app/common_results.cpp @@ -0,0 +1,116 @@ +#include "app/common.hpp" +#include "core/stability.hpp" +#include + +namespace gao::app { + +int driver_resets(const OptimizeOutcome& o) { return o.ran ? o.result.driver_resets : 0; } +int driver_resets(const UndervoltOutcome& o) { return o.ran ? o.result.driver_resets : 0; } +int driver_resets(const AllInOneOutcome& o) { return driver_resets(o.overclock) + driver_resets(o.undervolt); } + +namespace { + +// One measurement, as the result lines show it. with_mem: the memory clock too. +std::string measured_line(const char* name, const StabilityResult& s, bool with_mem) { + char mem[32] = ""; + if (with_mem) std::snprintf(mem, sizeof(mem), " mem=%d MHz", s.avg_mem_mhz); + char line[256]; + std::snprintf(line, sizeof(line), " %s score=%.0f it/s core=%d MHz%s peak=%d C fan=%s power=%d W (%.0f s)", name, s.score, + s.avg_core_mhz, mem, s.peak_temp_c, reading(s.end_fan_pct, " %").c_str(), s.avg_power_w, s.seconds); + return line; +} + +std::string not_applied(const std::string& reason, bool stock_restored) { + return "RESULT: not applied -- " + reason + " (" + (stock_restored ? "card at stock" : "reset to stock FAILED, run `gao --reset`") + ")"; +} + +void add_resets(std::vector& lines, int resets) { + if (resets > 0) lines.push_back(" driver resets during this run: " + std::to_string(resets)); +} + +void add_saved(std::vector& lines, bool saved, const std::string& save_note) { + if (!saved) lines.push_back(" not saved: " + save_note); +} + +} + +std::vector result_lines(const OptimizeOutcome& o) { + if (!o.ran) return {"RESULT: not started -- " + o.error}; + const OptimizeResult& r = o.result; + std::vector lines; + if (!r.ok) { + lines.push_back(not_applied(r.reason, r.stock_restored)); + add_resets(lines, driver_resets(o)); + return lines; + } + char line[256]; + std::snprintf(line, sizeof(line), "RESULT: power %d %%, core +%d MHz (confirmed +%d), mem +%d MHz (confirmed +%d)", r.power_pct, + r.core_mhz, r.core_confirmed, r.mem_mhz, r.mem_confirmed); + lines.push_back(line); + add_resets(lines, driver_resets(o)); + // Both runs last as long, so the temperature and the fan speed compare; at stock the driver had the fans. + lines.push_back(measured_line("before:", o.stock, true)); + lines.push_back(measured_line("after: ", r.soak, true)); + add_saved(lines, o.saved, o.save_note); + return lines; +} + +std::vector result_lines(const UndervoltOutcome& o) { + if (!o.ran) return {"RESULT: not started -- " + o.error}; + const UndervoltResult& r = o.result; + std::vector lines; + if (!r.ok) { + lines.push_back(not_applied(r.reason, r.stock_restored)); + add_resets(lines, driver_resets(o)); + return lines; + } + char line[256]; + std::snprintf(line, sizeof(line), "RESULT: %d MHz at %d mV instead of %d mV (lowest stable %d mV, confirmed %d mV)", r.freq_khz / 1000, + r.applied_uv / 1000, r.stock_uv / 1000, r.edge_uv / 1000, r.confirmed_uv / 1000); + lines.push_back(line); + add_resets(lines, driver_resets(o)); + lines.push_back(measured_line("before:", r.baseline, false)); + lines.push_back(measured_line("after: ", r.after, false)); + add_saved(lines, o.saved, o.save_note); + return lines; +} + +std::vector result_lines(const AllInOneOutcome& o) { + std::vector lines; + if (!o.ok) { + lines.push_back("RESULT: not applied -- " + o.error); + add_resets(lines, driver_resets(o)); + return lines; + } + const Profile& p = o.profile; + char line[256]; + int n = std::snprintf(line, sizeof(line), "RESULT: power %d %%, core +%d MHz, mem +%d MHz", p.power_pct, p.core_mhz, p.mem_mhz); + if (p.undervolt && n > 0 && n < static_cast(sizeof(line))) + n += std::snprintf(line + n, sizeof(line) - n, ", %d MHz at %d mV", p.undervolt->freq_khz / 1000, p.undervolt->volt_uv / 1000); + if (o.fan.ok && n > 0 && n < static_cast(sizeof(line))) + std::snprintf(line + n, sizeof(line) - n, ", fans %d %% at %d C", o.fan.hold_pct, o.fan.hold_temp_c); + lines.push_back(line); + add_resets(lines, driver_resets(o)); + if (!o.undervolt_note.empty()) lines.push_back(" no undervolt: " + o.undervolt_note); + if (!o.fan_note.empty()) lines.push_back(" fans: " + o.fan_note); + if (!o.disturbed.empty()) lines.push_back(" not clean: " + o.disturbed); + lines.push_back(measured_line("stock:", o.stock, true)); + lines.push_back(measured_line("now: ", o.now, true)); + if (o.stock.score > 0 && o.stock.avg_power_w > 0) { + std::snprintf(line, sizeof(line), " that is %+.1f %% score and %+.1f %% power against stock", (o.now.score / o.stock.score - 1) * 100, + (static_cast(o.now.avg_power_w) / o.stock.avg_power_w - 1) * 100); + lines.push_back(line); + } + return lines; +} + +std::vector result_lines(const RetestOutcome& o) { + std::vector lines; + if (o.result.test.seconds > 0) lines.push_back(measured_line(verdict_name(o.result.test.verdict), o.result.test, true)); + if (!o.ok) lines.push_back("RESULT: not applied -- " + o.error); + else + lines.push_back("RESULT: the tune holds on driver " + o.driver + " (it was tested on " + o.driver_was + ") and is applied: " + + profile_text(o.profile)); + return lines; +} +} diff --git a/src/app/common_retest.cpp b/src/app/common_retest.cpp new file mode 100644 index 0000000..64d454f --- /dev/null +++ b/src/app/common_retest.cpp @@ -0,0 +1,87 @@ +#include "app/common_internal.hpp" +#include "hw/app_files.hpp" +#include "hw/stress.hpp" +#include +#include + +namespace gao::app { + +RetestOutcome run_retest(const OptimizeHooks& hooks) { + RetestOutcome out; + auto fail = [&](const std::string& why) { out.error = why; return out; }; + if (const std::string tool = other_tool(); !tool.empty()) return fail(tool); + if (!is_elevated()) return fail("testing a tune sets clocks and power limits and needs administrator rights"); + const TuningLock lock; + if (!lock.owned()) return fail("another optimize is already running (in the app or on the command line)"); + std::string why; + if (!prepare_state(&why)) return fail(why); + if (const BusyGate busy = busy_before_run(hooks); !busy.refusal.empty()) return fail(busy.refusal); + GuardedGpu hw(kGpu); + if (!hw.Init(&why)) return fail(why); + auto load = std::make_unique(); + if (!load->Init()) return fail("stress init failed: " + load->Error()); + const GpuControl& gpu = hw.control(); + Config cfg = load_config(); + if (!cfg.profile) return fail("there is no saved tune to test"); + out.driver_was = cfg.profile->driver; + out.driver = hw.DriverVersion(); + const std::string gpu_id = hw.GpuUuid(); + const int limit = profile_ceiling(*cfg.profile); + // Every way out: the fans go back to the driver. The app's own fan curve + // driver takes them from there. + struct FansBack { + const GpuControl& gpu; + ~FansBack() { if (gpu.set_fan_auto) gpu.set_fan_auto(); } + } fans_back{gpu}; + const KeepAwake keep_awake; + // The fans as the tune will be used with: the app's curve when it drives + // them, the NVIDIA driver's own otherwise. + std::optional fans; + if (cfg.fan_control && gpu.set_fan_pct) + fans.emplace(gpu, active_fan_curve(cfg).value_or(default_curve(cfg.profile->preset)), limit, + fan_min_for(cfg, gpu_id, gpu.fan_min_pct)); + RetestIo io; + io.probe = [&](double seconds, int max_temp, double stall_below) { + auto read = [&] { + const Telemetry t = gpu.read(); + if (fans) fans->tick(t.temp_c, t.power_w, std::chrono::steady_clock::now(), t.power_limit_w); + return t; + }; + return run_stability([&] { return load->Batch(); }, read, seconds, max_temp, hooks.aborted, stall_below); + }; + io.log = hooks.log; + io.measuring = hooks.measuring; + if (hooks.active_gpu) hooks.active_gpu(&gpu); + out.ran = true; + const bool crashed = !guarded([&] { + out.result = retest_saved_tune(cfg, out.driver, gpu_id, gpu, io); + if (fans) fans->release(); + }); + if (hooks.active_gpu) hooks.active_gpu(nullptr); + auto to_stock = [&](const std::string& reason) { + const bool stock = gpu.reset_to_stock && gpu.reset_to_stock(); + return fail(reason + (stock ? " -- card at stock" : " -- reset to stock FAILED, run `gao --reset`")); + }; + if (crashed) { + if (hooks.measuring) hooks.measuring("", 0); + (void)load.release(); // as in run_optimize: a load that faulted is left alone + return to_stock("access violation while the tune was tested"); + } + if (!out.result.ok) { + if (out.result.test.seconds > 0) + boot_log("the saved tune was tested on driver " + out.driver + " and is not applied: " + out.result.reason); + return fail(out.result.reason + (out.result.at_stock ? " -- card at stock" : " -- reset to stock FAILED, run `gao --reset`")); + } + // Only the driver the tune belongs to, and the strikes, are this test's to change. + Config latest = load_config(); + if (!latest.profile) return to_stock("the saved tune was removed while it was tested"); + latest.profile->driver = out.driver; + latest.boot_strikes = 0; + if (!save_config(latest)) return to_stock("the tune held, but that could not be saved (" + config_path().string() + ")"); + out.ok = true; + out.profile = *latest.profile; + boot_log("tested again on driver " + out.driver + " (it was tested on " + out.driver_was + "): applied " + profile_text(out.profile)); + return out; +} + +} diff --git a/src/app/common_run_steps.cpp b/src/app/common_run_steps.cpp new file mode 100644 index 0000000..3169beb --- /dev/null +++ b/src/app/common_run_steps.cpp @@ -0,0 +1,188 @@ +#include "app/common_internal.hpp" +#include +#include "core/watchdog.hpp" +#include "hw/gpu_users.hpp" +#include "hw/nvidia_app.hpp" +#include "hw/stress.hpp" +#include +#include +#include + +namespace gao::app { + +MeasuredRun measured_run(const StabilityResult& r) { + return {r.score, r.avg_core_mhz, r.avg_mem_mhz, r.peak_temp_c, r.end_fan_pct, r.avg_power_w}; +} + +// Both runs, or nothing: a comparison with half its numbers says nothing. +std::optional against_stock(const StabilityResult& stock, const StabilityResult& tuned) { + if (stock.score <= 0 || tuned.score <= 0) return std::nullopt; + return AgainstStock{measured_run(stock), measured_run(tuned)}; +} + +namespace { + +constexpr const char* kAutoTuningRefusal = + "Automatic Tuning is switched on in the NVIDIA app: NVIDIA's own scanner loads the card and changes its clocks when it sees fit, " + "also in the middle of a run. Switch it off (NVIDIA app, System, Performance) and start again. If the NVIDIA app shows it as off " + "already, restart the PC and look again"; + +// After a measurement at stock that the temperature stopped: without load, +// this far below it before the run loads the card again, or this long. The +// steps that follow have limits of their own. +constexpr int kCoolDownC = 15; +constexpr int kCoolDownMaxS = 60; + +std::string stock_line(const StabilityResult& s) { + char line[400]; + std::snprintf(line, sizeof(line), " stock: %s score=%.0f it/s clock=%d MHz peak=%d C fan=%s power=%d W", verdict_name(s.verdict), + s.score, s.avg_core_mhz, s.peak_temp_c, reading(s.end_fan_pct, " %").c_str(), s.avg_power_w); + return line; +} +} + +// Why no run starts next to another tuning tool (core/search.hpp); empty when +// none runs. A list of programs that cannot be had reads as none: this check +// is a courtesy, the run's own verifications are what it relies on. +std::string other_tool() { + std::vector running; + const HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap == INVALID_HANDLE_VALUE) return {}; + PROCESSENTRY32W e{}; + e.dwSize = sizeof(e); + for (BOOL more = Process32FirstW(snap, &e); more; more = Process32NextW(snap, &e)) { + std::string name; // the names looked for are ASCII; anything else cannot match + for (const wchar_t* c = e.szExeFile; *c; ++c) name += *c < 128 ? static_cast(*c) : '?'; + running.push_back(std::move(name)); + } + CloseHandle(snap); + if (const std::string tool = other_tool_refusal(running); !tool.empty()) return tool; + // The NVIDIA app may run; its Automatic Tuning may not be on. Unknown + // (nothing documents where it is kept) counts as off. + if (nvidia_auto_tuning().value_or(false)) return kAutoTuningRefusal; + return {}; +} + +// The look at what else uses the card, and the question about it, before a +// run (core/busy_card.hpp). A run that is one step of a longer one does not +// ask again. +BusyGate busy_before_run(const OptimizeHooks& hooks) { + if (hooks.busy_asked) return {}; + return busy_gate( + [&] { + if (hooks.measuring) hooks.measuring("Looking at what else uses the card", kBusyLookS); + auto use = gpu_use(kBusyLookS); + if (hooks.measuring) hooks.measuring("", 0); + return use; + }, + hooks.busy, [&](const std::string& m) { if (hooks.log) hooks.log(m); }); +} + +StockOutcome measure_stock(const OptimizeHooks& hooks) { + StockOutcome out; + auto fail = [&](const std::string& why) { out.error = why; return out; }; + const TuningLock lock; + if (!lock.owned()) return fail("another optimize is already running (in the app or on the command line)"); + std::string why; + if (!prepare_state(&why)) return fail(why); + GuardedGpu hw(kGpu); + if (!hw.Init(&why)) return fail(why); + auto load = std::make_unique(); + if (!load->Init()) return fail("stress init failed: " + load->Error()); + const GpuControl& gpu = hw.control(); + if (!gpu.reset_to_stock || !gpu.reset_to_stock()) return fail("could not set the card to stock"); + const KeepAwake keep_awake; + auto log = [&](const std::string& m) { if (hooks.log) hooks.log(m); }; + // In every run's log, whatever the profile: a target that another tool + // left on the card makes this a measurement of something else than stock. + log(temp_target_text(gpu.temp_target_c, gpu.temp_target_default_c)); + if (hooks.measuring) hooks.measuring("Measuring the card at stock", kLongRunS); + // What the card did meanwhile, a line every ten seconds: the readings the + // measurement takes anyway, written down. Nothing of the measurement changes. + TraceClock trace; + const auto began = std::chrono::steady_clock::now(); + std::optional> unlogged; // the latest reading that got no line + const bool crashed = !guarded([&] { + out.result = run_stability( + [&] { return load->Batch(); }, + [&] { + const Telemetry t = gpu.read(); + const double at = std::chrono::duration(std::chrono::steady_clock::now() - began).count(); + if (t.ok && trace.due(at)) { + log(trace_line(at, t)); + unlogged.reset(); + } else if (t.ok) { + unlogged = std::make_pair(at, t); + } + return t; + }, + kLongRunS, kStockTempC, hooks.aborted); + }); + if (hooks.measuring) hooks.measuring("", 0); + if (crashed) { + (void)load.release(); // as in run_optimize: a load that faulted is left alone + return fail("access violation while the card was measured at stock"); + } + if (unlogged) log(trace_line(unlogged->first, unlogged->second)); // how it ended, when that fell between two lines + log(stock_line(out.result)); + out.error = stock_refusal(out.result); + if (!out.error.empty() || out.result.verdict != Verdict::TooHot) return out; + // The driver's own fan control let the card reach the limit. That is the + // "before"; the run goes on, with the fans on a curve from here. + log(" the card reached " + std::to_string(out.result.peak_temp_c) + " C at stock with the driver's own fan control (fans at " + + reading(out.result.end_fan_pct, " %") + "): the measurement stopped there, after " + + std::to_string(static_cast(out.result.seconds)) + " s. The run goes on with the fans on a curve."); + if (hooks.measuring) hooks.measuring("Letting the card cool down", kCoolDownMaxS); + for (int i = 0; i < kCoolDownMaxS * 4 && !(hooks.aborted && hooks.aborted()); ++i) { + const Telemetry t = gpu.read(); + if (!t.ok || t.temp_c <= kStockTempC - kCoolDownC) break; + Sleep(250); + } + if (hooks.measuring) hooks.measuring("", 0); + return out; +} + +// One reading of a run, with the fans moved along their curve. +Telemetry read_with_fans(const GpuControl& gpu, FanDriver& fans, const OptimizeHooks& hooks) { + const Telemetry t = gpu.read(); + const FanMode before = fans.state().mode; + const FanState now = fans.tick(t.temp_c, t.power_w, std::chrono::steady_clock::now(), t.power_limit_w); + if (now.mode != before && now.mode == FanMode::Failed && hooks.log) hooks.log("fans: a write did not verify; the driver has them again"); + if (now.mode != before && now.mode == FanMode::Foreign && hooks.log) hooks.log("fans: another program set them; leaving them alone"); + return t; +} + +std::vector busy_now() { return busy_programs(gpu_use(kBusyLookS)); } + +std::optional tune_before_run() { + const Config cfg = load_config(); + if (!cfg.profile) return std::nullopt; + GuardedGpu hw(kGpu); + std::string why; + if (!hw.Init(&why) || !tune_on_card(hw.control(), *cfg.profile)) return std::nullopt; + return cfg.profile; +} + +bool put_back_after_stop(const std::optional& before, const OptimizeHooks& hooks) { + if (!before) return false; + auto log = [&](const std::string& m) { if (hooks.log) hooks.log(m); }; + const TuningLock lock; // the watchdog keeps out of it meanwhile + GuardedGpu hw(kGpu); + std::string why; + if (!hw.Init(&why)) { + log("stopped: the tune that was on the card could not be put back (" + why + ")"); + return false; + } + const GpuControl& gpu = hw.control(); + if (tune_on_card(gpu, *before)) return true; // stopped before the card was touched + bool clean = false; + if (!apply_profile(gpu, *before, &why, &clean)) { + log("stopped: the tune that was on the card could not be put back (" + why + ") -- " + + (clean ? "card at stock" : "reset to stock FAILED, run `gao --reset`")); + return false; + } + log("stopped: the tune that was on the card before the run is back: " + profile_text(*before)); + return true; +} + +} From 780a90d6aaedaa9aff65fe5e2cde1d1b5d2b5128 Mon Sep 17 00:00:00 2001 From: Bejeweld Date: Sun, 18 Oct 2026 11:22:55 +0200 Subject: [PATCH 03/10] refactor(gui): split ui.cpp into widgets and pages Move the shared colours, icons, fonts, widgets and text helpers to ui_widgets.{hpp,cpp}, and the dashboard, Fan, Optimize (choose and run screen), results and About pages to their own files. ui.cpp keeps the frame and navigation. Code is moved unchanged; g_bold has one definition and g_regular and g_full_log stay in the only file that uses them. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 4 + src/app/gui/ui.cpp | 1312 +--------------------------------- src/app/gui/ui_about.cpp | 31 + src/app/gui/ui_dashboard.cpp | 246 +++++++ src/app/gui/ui_fan.cpp | 233 ++++++ src/app/gui/ui_optimize.cpp | 313 ++++++++ src/app/gui/ui_results.cpp | 213 ++++++ src/app/gui/ui_widgets.cpp | 291 ++++++++ src/app/gui/ui_widgets.hpp | 103 +++ 9 files changed, 1435 insertions(+), 1311 deletions(-) create mode 100644 src/app/gui/ui_about.cpp create mode 100644 src/app/gui/ui_dashboard.cpp create mode 100644 src/app/gui/ui_fan.cpp create mode 100644 src/app/gui/ui_optimize.cpp create mode 100644 src/app/gui/ui_results.cpp create mode 100644 src/app/gui/ui_widgets.cpp create mode 100644 src/app/gui/ui_widgets.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e504f79..29a1458 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -130,6 +130,10 @@ target_link_libraries(imgui PUBLIC d3d11 dwmapi PRIVATE gao_hardening) add_executable(GpuAutoOptimizer WIN32 src/app/gui/main_gui.cpp src/app/gui/ui.hpp src/app/gui/ui.cpp + src/app/gui/ui_widgets.hpp src/app/gui/ui_widgets.cpp + src/app/gui/ui_dashboard.cpp src/app/gui/ui_fan.cpp + src/app/gui/ui_optimize.cpp src/app/gui/ui_results.cpp + src/app/gui/ui_about.cpp src/app/gui/worker.hpp src/app/gui/worker.cpp src/app/gao.manifest src/app/app.rc diff --git a/src/app/gui/ui.cpp b/src/app/gui/ui.cpp index d470c70..2d26e30 100644 --- a/src/app/gui/ui.cpp +++ b/src/app/gui/ui.cpp @@ -1,1261 +1,12 @@ #include "app/gui/ui.hpp" -#include "core/boot.hpp" -#include "core/objectives.hpp" +#include "app/gui/ui_widgets.hpp" #include "core/version.hpp" -#include "core/watchdog.hpp" -#include "imgui.h" -#include -#include -#include -#include namespace gao::gui { namespace { constexpr size_t kHistory = 60; -constexpr size_t kLogShort = 6; // log lines on the dashboard before "View full log" - -ImFont* g_regular = nullptr; -ImFont* g_bold = nullptr; -bool g_full_log = false; - -const ImVec4 kBg{0.067f, 0.075f, 0.090f, 1}; -const ImVec4 kNav{0.050f, 0.057f, 0.069f, 1}; -const ImVec4 kCard{0.090f, 0.102f, 0.122f, 1}; -const ImVec4 kInner{0.110f, 0.125f, 0.149f, 1}; -const ImVec4 kInnerHover{0.140f, 0.158f, 0.188f, 1}; -const ImVec4 kBorder{0.170f, 0.192f, 0.227f, 1}; -const ImVec4 kAccent{0.180f, 0.435f, 0.835f, 1}; -const ImVec4 kAccentHover{0.231f, 0.502f, 0.906f, 1}; -const ImVec4 kAccentBright{0.290f, 0.580f, 1.000f, 1}; -const ImVec4 kSelectedBg{0.098f, 0.145f, 0.220f, 1}; -const ImVec4 kText{0.900f, 0.910f, 0.930f, 1}; -const ImVec4 kDim{0.600f, 0.640f, 0.690f, 1}; -const ImVec4 kGood{0.360f, 0.780f, 0.500f, 1}; -const ImVec4 kWarn{0.910f, 0.710f, 0.230f, 1}; -const ImVec4 kBad{0.930f, 0.400f, 0.400f, 1}; - -// Segoe Fluent Icons (Windows 11) / Segoe MDL2 Assets (Windows 10) code points. -enum Icon : unsigned { - kIconHome = 0xE80F, kIconPulse = 0xE9D9, kIconInfo = 0xE946, kIconChip = 0xEEA1, kIconMemory = 0xE964, - kIconTemp = 0xE9CA, kIconPower = 0xE945, kIconFan = 0xF16A, kIconRefresh = 0xE72C, kIconUndo = 0xE777, - kIconPlay = 0xF5B0, kIconStop = 0xE71A, kIconClock = 0xE823, kIconStar = 0xE734, kIconQuiet = 0xE992, - kIconLeaf = 0xEC0A, kIconGauge = 0xEC4A, kIconShield = 0xEA18, kIconList = 0xE8FD, kIconBack = 0xE72B, - kIconDownload = 0xE896, kIconStarFilled = 0xE735, -}; - -// UTF-8 for a code point in the Basic Multilingual Plane's private use area. -std::string ic(unsigned cp) { - const char s[4] = {static_cast(0xE0 | (cp >> 12)), static_cast(0x80 | ((cp >> 6) & 0x3F)), - static_cast(0x80 | (cp & 0x3F)), 0}; - return s; -} -std::string with_icon(unsigned cp, const char* text) { return ic(cp) + " " + text; } - -float em() { return ImGui::GetFontSize(); } -float base() { return ImGui::GetStyle().FontSizeBase; } - -// The fan icon is drawn, not a glyph: the icon fonts of Windows have no fan -// (kIconFan's code point is a ring of dots). A casing ring, a hub and three -// swept blades inside a circle of `radius` around `c`. -void draw_fan(ImDrawList* dl, const ImVec2& c, float radius, ImU32 colour) { - constexpr float kTurn = 6.2831853f; - constexpr int kOutline = 20; // points around one blade - dl->AddCircle(c, radius * 0.92f, colour, 0, std::max(1.0f, radius * 0.12f)); - for (int k = 0; k < 3; ++k) { - // A blade: an ellipse half-way out on its spoke, leaning 52 degrees into the turn. - const float spoke = kTurn * static_cast(k) / 3 - kTurn / 4, lean = spoke + 0.9076f; - const ImVec2 mid(c.x + radius * 0.50f * std::cos(spoke), c.y + radius * 0.50f * std::sin(spoke)); - ImVec2 outline[kOutline]; - for (int i = 0; i < kOutline; ++i) { - const float t = kTurn * static_cast(i) / kOutline; - const float x = radius * 0.34f * std::cos(t), y = radius * 0.21f * std::sin(t); - outline[i] = ImVec2(mid.x + x * std::cos(lean) - y * std::sin(lean), mid.y + x * std::sin(lean) + y * std::cos(lean)); - } - dl->AddConvexPolyFilled(outline, kOutline, colour); - } - dl->AddCircleFilled(c, radius * 0.20f, colour); -} - -struct PresetInfo { - Preset preset; - Icon icon; - const char* title; - const char* summary; -}; -constexpr PresetInfo kPresets[] = { - {Preset::BestOfMyGpu, kIconStar, "Best of my GPU", - "Highest confirmed clocks with a safety margin and the most power the card allows."}, - {Preset::CoolAndEfficient, kIconLeaf, "Cool & efficient", - "Power only: the lowest power limit that costs under 2 % speed, clocks left stock."}, - {Preset::Quiet, kIconQuiet, "Quiet", "Milder clocks and the lowest power limit that costs under 2 % speed."}, - {Preset::MaxPerformance, kIconGauge, "Max performance", - "Highest confirmed clocks minus one step and the highest power limit."}, -}; -// The one that does it all, shown first and across the full width. -constexpr PresetInfo kAllInOne = {Preset::AllInOne, kIconStarFilled, "All in one", - "Overclock, then an undervolt on top of it, then the quietest fan curve that holds the " - "temperature: more speed than stock on less power, tested as a whole. One click, about 40 " - "minutes."}; -// Not a clock search, and shown apart from the four above: the speed the -// card has at stock, on the lowest voltage that holds it. -constexpr PresetInfo kUndervolt = {Preset::Undervolt, kIconPower, "Undervolt", - "The same speed on less voltage: less power, less heat and quieter fans. Clocks and the " - "power limit stay stock. It replaces a saved overclock."}; - -const char* title_of(Preset preset) { - if (preset == kUndervolt.preset) return kUndervolt.title; - if (preset == kAllInOne.preset) return kAllInOne.title; - for (const PresetInfo& p : kPresets) - if (p.preset == preset) return p.title; - return preset_name(preset); -} - -std::string offset(int mhz) { return (mhz >= 0 ? "+" : "") + std::to_string(mhz) + " MHz"; } -std::string duration_text(double seconds) { - char buf[32]; - if (seconds < 90) std::snprintf(buf, sizeof(buf), "%.0f s", seconds); - else std::snprintf(buf, sizeof(buf), "%.0f min", seconds / 60); - return buf; -} -std::string undervolt_text(const UndervoltTune& u) { - return std::to_string(u.freq_khz / 1000) + " MHz at " + std::to_string(u.volt_uv / 1000) + " mV"; -} - -// ------------------------------------------------------------------ widgets - -void text_bold(const char* text, float scale = 1.0f) { - ImGui::PushFont(g_bold, base() * scale); - ImGui::TextUnformatted(text); - ImGui::PopFont(); -} -void heading(const char* text) { text_bold(text, 1.2f); } -void dim(const char* text) { - ImGui::PushStyleColor(ImGuiCol_Text, kDim); - ImGui::TextUnformatted(text); - ImGui::PopStyleColor(); -} -void wrapped(const ImVec4& color, const std::string& text) { - ImGui::PushStyleColor(ImGuiCol_Text, color); - ImGui::PushTextWrapPos(0); - ImGui::TextUnformatted(text.c_str()); - ImGui::PopTextWrapPos(); - ImGui::PopStyleColor(); -} - -// A rounded panel. height 0: as tall as its contents. Always pair with end_card(). -// It never scrolls itself: the wheel goes to the page behind it. (A panel as -// tall as its contents can still be a pixel short of them, and then it -// kept the wheel: the page did not scroll with the mouse on such a card.) -void begin_card(const char* id, float height = 0, const ImVec4& bg = kCard, float width = 0) { - ImGui::PushStyleColor(ImGuiCol_ChildBg, bg); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(em() * 0.9f, em() * 0.75f)); - ImGui::BeginChild(id, ImVec2(width, height), - ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding | - (height == 0 ? ImGuiChildFlags_AutoResizeY : ImGuiChildFlags_None), - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); - ImGui::PopStyleVar(); - ImGui::PopStyleColor(); -} -void end_card() { ImGui::EndChild(); } - -// Puts the next item at the right edge of the current line, `width` wide. -void align_right(float width) { - ImGui::SameLine(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - width); -} - -float button_width(const std::string& label) { - return ImGui::CalcTextSize(label.c_str()).x + ImGui::GetStyle().FramePadding.x * 2; -} - -bool primary_button(const std::string& label, ImVec2 size = ImVec2(0, 0)) { - ImGui::PushStyleColor(ImGuiCol_Button, kAccent); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, kAccentHover); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, kAccentBright); - ImGui::PushStyleColor(ImGuiCol_Border, kAccent); - const bool pressed = ImGui::Button(label.c_str(), size); - ImGui::PopStyleColor(4); - return pressed; -} - -// An on/off switch; returns true when clicked. -bool toggle(const char* id, bool on) { - const float h = em() * 1.25f, w = h * 1.9f; - const ImVec2 p = ImGui::GetCursorScreenPos(); - const bool clicked = ImGui::InvisibleButton(id, ImVec2(w, h)); - ImDrawList* dl = ImGui::GetWindowDrawList(); - const ImVec4 track = on ? kAccent : (ImGui::IsItemHovered() ? kInnerHover : kInner); - dl->AddRectFilled(p, ImVec2(p.x + w, p.y + h), ImGui::GetColorU32(track), h / 2); - dl->AddRect(p, ImVec2(p.x + w, p.y + h), ImGui::GetColorU32(on ? kAccent : kBorder), h / 2); - const float r = h / 2 - h * 0.15f; - dl->AddCircleFilled(ImVec2(on ? p.x + w - h / 2 : p.x + h / 2, p.y + h / 2), r, ImGui::GetColorU32(kText)); - return clicked; -} - -// A telemetry tile: icon, label, value. -void tile(const char* id, Icon icon, const char* label, const std::string& value, float w, float h) { - ImGui::PushStyleColor(ImGuiCol_ChildBg, kInner); - ImGui::BeginChild(id, ImVec2(w, h), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar); - ImGui::PopStyleColor(); - const float content_h = em() * 2.45f; - ImGui::SetCursorPos(ImVec2(em() * 0.9f, (h - content_h) / 2 + em() * 0.3f)); - ImGui::PushFont(nullptr, base() * 1.8f); - ImGui::PushStyleColor(ImGuiCol_Text, kAccentBright); - if (icon == kIconFan) { // drawn, in the box a glyph of this size would take - const ImVec2 at = ImGui::GetCursorScreenPos(); - const float size = ImGui::GetFontSize(); - ImGui::Dummy(ImVec2(size, size)); - draw_fan(ImGui::GetWindowDrawList(), ImVec2(at.x + size / 2, at.y + size / 2), size * 0.5f, ImGui::GetColorU32(kAccentBright)); - } else { - ImGui::TextUnformatted(ic(icon).c_str()); - } - ImGui::PopStyleColor(); - ImGui::PopFont(); - ImGui::SameLine(0, em() * 0.8f); - ImGui::SetCursorPosY((h - content_h) / 2); - // Label and value as one block, as tall as content_h says: with the - // style's own line spacing between them the value hung at the bottom. - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, em() * 0.15f)); - ImGui::BeginGroup(); - dim(label); - // A value that is wider than the tile is drawn smaller, down to the - // label's own size, so that it is never cut off. - float scale = 1.3f; - ImGui::PushFont(g_bold, base() * scale); - const float need = ImGui::CalcTextSize(value.c_str()).x; - ImGui::PopFont(); - const float room = ImGui::GetContentRegionAvail().x - em() * 0.3f; - if (need > room && need > 0) scale = std::max(1.0f, scale * room / need); - text_bold(value.c_str(), scale); - ImGui::EndGroup(); - ImGui::PopStyleVar(); - ImGui::EndChild(); -} - -void telemetry_tiles(const UiState& s) { - const Telemetry& t = s.telemetry; - const float gap = ImGui::GetStyle().ItemSpacing.x; - const float w = (ImGui::GetContentRegionAvail().x - gap * 4) / 5, h = em() * 4.2f; - tile("core", kIconChip, "Core clock", reading(t.core_mhz, " MHz"), w, h); - ImGui::SameLine(); - tile("mem", kIconMemory, "Memory clock", reading(t.mem_mhz, " MHz"), w, h); - ImGui::SameLine(); - tile("temp", kIconTemp, "Temperature", reading(t.temp_c, " \xC2\xB0""C"), w, h); - ImGui::SameLine(); - tile("power", kIconPower, "Power", reading(t.power_w, "") + " / " + reading(t.power_limit_w, " W"), w, h); - ImGui::SameLine(); - // Who has the fans: the app's curve, the NVIDIA driver, another program, - // or, during a run, the run itself (its curve, or its fan measurement). - const char* mode = fan_owner_label( - fan_owner(s.fan_state.mode, s.fan_control && s.fan_available, s.run_active, s.run_tunes_fans, s.fan_manual)); - tile("fan", kIconFan, "Fan speed", reading(t.fan_pct, " %") + mode, w, h); -} - -// An icon in a square badge, centred by the glyph's own outline and not by -// its advance: the glyphs of the icon font differ in width and in where -// they sit in their cell, which next to a title reads as uneven spacing. -void icon_badge(Icon icon, float side, const ImVec4& fill, const ImVec4& edge, const ImVec4& ink) { - const ImVec2 at = ImGui::GetCursorScreenPos(); - ImGui::Dummy(ImVec2(side, side)); - ImDrawList* dl = ImGui::GetWindowDrawList(); - const ImVec2 corner(at.x + side, at.y + side); - dl->AddRectFilled(at, corner, ImGui::GetColorU32(fill), side * 0.28f); - dl->AddRect(at, corner, ImGui::GetColorU32(edge), side * 0.28f); - ImGui::PushFont(nullptr, side * 0.5f); - ImVec2 pen(at.x + side * 0.25f, at.y + side * 0.25f); // a glyph the font does not have: roughly centred - if (const ImFontGlyph* glyph = ImGui::GetFontBaked()->FindGlyph(static_cast(icon))) - pen = ImVec2(at.x + (side - glyph->X0 - glyph->X1) / 2, at.y + (side - glyph->Y0 - glyph->Y1) / 2); - dl->AddText(ImVec2(std::floor(pen.x + 0.5f), std::floor(pen.y + 0.5f)), ImGui::GetColorU32(ink), ic(icon).c_str()); - ImGui::PopFont(); -} - -// One profile as a card; clicking it selects it. h 0: as tall as its contents. -// The selected card says so with its border and its badge; there is no -// radio button next to them saying it a third time. -void preset_card(UiState& s, const PresetInfo& p, float w, float h) { - ImGui::PushID(static_cast(p.preset)); - const bool selected = s.preset == p.preset; - ImGui::PushStyleColor(ImGuiCol_ChildBg, selected ? kSelectedBg : kInner); - ImGui::PushStyleColor(ImGuiCol_Border, selected ? kAccentBright : kBorder); - ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, selected ? 2.0f : 1.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(em() * 0.8f, em() * 0.7f)); - ImGui::BeginChild("preset", ImVec2(w, h), - ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding | - (h == 0 ? ImGuiChildFlags_AutoResizeY : ImGuiChildFlags_None), - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); - ImGui::PopStyleVar(2); - ImGui::PopStyleColor(2); - const float badge = em() * 2.1f; - icon_badge(p.icon, badge, selected ? kAccent : kCard, selected ? kAccentBright : kBorder, selected ? kText : kDim); - ImGui::SameLine(0, em() * 0.7f); - ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (badge - em() * 1.05f) / 2 - em() * 0.08f); // level with the badge - text_bold(p.title, 1.05f); - wrapped(kDim, std::string(p.summary) + " Up to " + std::to_string(objectives_for(p.preset).max_temp_c) + - " \xC2\xB0""C."); - ImGui::EndChild(); - if (ImGui::IsItemClicked()) s.preset = p.preset; - ImGui::PopID(); -} - -// All in one first, across the full width; the four clock profiles side by -// side; and the undervolt below them, the other kind of tune. -void preset_cards(UiState& s) { - preset_card(s, kAllInOne, ImGui::GetContentRegionAvail().x, 0); - const float gap = ImGui::GetStyle().ItemSpacing.x; - const float w = (ImGui::GetContentRegionAvail().x - gap * 3) / 4; - for (size_t i = 0; i < std::size(kPresets); ++i) { - preset_card(s, kPresets[i], w, em() * 8.3f); - if (i + 1 < std::size(kPresets)) ImGui::SameLine(); - } - preset_card(s, kUndervolt, ImGui::GetContentRegionAvail().x, 0); // as tall as its text, at any width -} - -// ------------------------------------------------------------------ dashboard - -void gpu_card(const UiState& s, const UiActions& act) { - begin_card("gpu"); - const float top = ImGui::GetCursorPosY(); - ImGui::BeginGroup(); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, em() * 0.15f)); - text_bold(s.gpu_name.empty() ? "No NVIDIA GPU found" : s.gpu_name.c_str(), 1.6f); - dim(("Driver " + (s.driver.empty() ? std::string("unknown") : s.driver)).c_str()); - ImGui::PopStyleVar(); - ImGui::EndGroup(); - const std::string detect = with_icon(kIconRefresh, "Detect GPU"); - align_right(button_width(detect)); - ImGui::SetCursorPosY(top); - if (ImGui::Button(detect.c_str())) act.detect_gpu(); - ImGui::Spacing(); - telemetry_tiles(s); - end_card(); -} - -void tuning_row(const char* label, const std::string& value, const ImVec4& color = kText) { - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - dim(label); - ImGui::TableNextColumn(); - ImGui::PushStyleColor(ImGuiCol_Text, color); - ImGui::PushTextWrapPos(0); - ImGui::TextUnformatted(value.c_str()); - ImGui::PopTextWrapPos(); - ImGui::PopStyleColor(); -} - -// "5922 -> 6067 it/s (+2.4 %)", drawn with an arrow: a stock value next to -// the tune's; the change in percent only where it says something (score, power). -std::string stock_to_tune(double stock, double tuned, const char* unit, bool percent) { - char buf[96]; - if (stock < 0 || tuned < 0) return "Not measured"; - if (percent && stock > 0) - std::snprintf(buf, sizeof(buf), "%.0f \xE2\x86\x92 %.0f%s (%+.1f %%)", stock, tuned, unit, (tuned / stock - 1) * 100); - else - std::snprintf(buf, sizeof(buf), "%.0f \xE2\x86\x92 %.0f%s", stock, tuned, unit); - return buf; -} - -void tuning_card(UiState& s, const UiActions& act) { - begin_card("tuning"); - heading("Current tuning"); - const float gap = ImGui::GetStyle().ItemSpacing.x; - // The rows first, and the card after them: a value that wraps takes more - // than one line, and a card that counted rows cut the last one off. - struct Row { - const char* label; - std::string value; - ImVec4 color; - }; - std::vector rows; - auto row = [&rows](const char* label, const std::string& value, const ImVec4& color = kText) { rows.push_back({label, value, color}); }; - { - if (s.applied) { - const AppliedState& a = *s.applied; - // With an undervolt saved the curve has its say too: the offsets read +0 under a flat top. - const bool undervolt = s.profile && s.profile->undervolt; - const bool stock = a.core_mhz == 0 && a.mem_mhz == 0 && a.power_pct >= 99 && a.power_pct <= 101 && - (!undervolt || s.curve == CurveState::Stock); - const bool ours = s.profile && tune_applied(*s.profile, a, s.curve); - if (ours) row("Applied now", std::string(title_of(s.profile->preset)) + " (the saved tune)", kGood); - else if (stock) row("Applied now", "Stock"); - else row("Applied now", "Set by another program", kWarn); - if (undervolt) - row("Undervolt", ours ? undervolt_text(*s.profile->undervolt) - : s.curve == CurveState::Stock ? "Not applied" - : s.curve ? "Not applied (the curve carries other offsets)" - : "Unknown (the curve could not be read)", - ours ? kText : kDim); - row("Core clock offset", offset(a.core_mhz)); - row("Memory clock offset", offset(a.mem_mhz)); - row("Power limit", std::to_string(a.power_pct) + " %"); - } else { - row("Applied now", "Unknown (the driver does not report it)", kDim); - } - if (s.profile) { - const Profile& p = *s.profile; - std::string tune = std::to_string(p.power_pct) + " %, core " + offset(p.core_mhz) + ", memory " + offset(p.mem_mhz); - if (p.undervolt) { // clocks and power are stock under an undervolt: name only what is not - tune = undervolt_text(*p.undervolt); - if (p.power_pct != 100) tune += ", " + std::to_string(p.power_pct) + " %"; - if (p.mem_mhz != 0) tune += ", memory " + offset(p.mem_mhz); - } - row("Saved tune", std::string(title_of(p.preset)) + ": " + tune + " (" + p.saved_at + ")"); - // What the run that saved it measured under full load, five minutes each. - if (const auto& m = p.against_stock; m && s.profile_gpu_ok) { - row("Speed vs stock", stock_to_tune(m->stock.score, m->tuned.score, " it/s", true)); - row("Power vs stock", stock_to_tune(m->stock.power_w, m->tuned.power_w, " W", true)); - row("Fans under load", stock_to_tune(m->stock.fan_pct, m->tuned.fan_pct, " %", false)); - row("Peak temperature", stock_to_tune(m->stock.peak_temp_c, m->tuned.peak_temp_c, " \xC2\xB0""C", false)); - } else if (s.profile_gpu_ok) { - row("Against stock", "Measured by the next run: this tune was saved by an older version.", kDim); - } - if (!s.profile_gpu_ok) - row("", "Tuned on " + std::string(s.profile_driver_ok ? "" : "driver " + p.driver + " and ") + - "another card. Optimize again before relying on it.", - kWarn); - else if (!s.profile_driver_ok) - row("", "The driver was updated: this tune was tested on " + p.driver + - (s.driver.empty() ? "" : ", and this is " + s.driver) + - ". It is not applied until it has been tested again.", - kWarn); - } else { - row("Saved tune", "None yet. Pick a profile and optimize.", kDim); - } - if (s.strikes > 0) - row("Crash strikes", std::to_string(s.strikes) + " of " + std::to_string(kMaxBootStrikes) + - " (apply at logon stops at " + std::to_string(kMaxBootStrikes) + ")", - kWarn); - } - const ImGuiStyle& style = ImGui::GetStyle(); - const float right_w = std::max(em() * 17, ImGui::GetContentRegionAvail().x * 0.36f); - const float left_w = ImGui::GetContentRegionAvail().x - right_w - gap; - const float label_w = em() * 10; - // What is left for a value: the card's padding and the table's cell padding off both columns. - const float value_w = std::max(em() * 8, left_w - em() * 0.9f * 2 - label_w - style.CellPadding.x * 4); - float rows_h = 0; - for (const Row& r : rows) - rows_h += std::max(em(), ImGui::CalcTextSize(r.value.c_str(), nullptr, false, value_w).y) + style.CellPadding.y * 2; - // Four buttons in the actions column, with the spacing between them and the column's padding. - const float buttons_h = em() * 2.3f * 4 + style.ItemSpacing.y * 3 + style.WindowPadding.y * 2; - const float h = std::max(buttons_h, rows_h + em() * 1.8f); - - begin_card("values", h, kInner, left_w); - if (ImGui::BeginTable("rows", 2, ImGuiTableFlags_SizingFixedFit)) { - ImGui::TableSetupColumn("label", ImGuiTableColumnFlags_WidthFixed, label_w); - ImGui::TableSetupColumn("value", ImGuiTableColumnFlags_WidthStretch); - for (const Row& r : rows) tuning_row(r.label, r.value, r.color); - ImGui::EndTable(); - } - end_card(); - - ImGui::SameLine(); - ImGui::PushStyleColor(ImGuiCol_ChildBg, kInner); - ImGui::BeginChild("actions", ImVec2(0, h), ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding); - ImGui::PopStyleColor(); - const float bw = ImGui::GetContentRegionAvail().x, bh = em() * 2.3f; - if (s.elevated && s.profile && s.profile_gpu_ok && !s.profile_driver_ok && !s.driver.empty()) { - // After a driver update the tune is not applied as it is: five minutes show whether it still holds. - if (primary_button("Test the tune (5 min)", ImVec2(bw, bh))) act.retest(); - } else if (s.elevated) { - ImGui::BeginDisabled(!s.profile); - if (primary_button("Apply saved tune", ImVec2(bw, bh))) act.apply_profile(); - ImGui::EndDisabled(); - } else { - if (primary_button("Restart as administrator", ImVec2(bw, bh))) act.restart_elevated(); - } - // Apply at logon: a framed row with a switch. - ImGui::PushStyleColor(ImGuiCol_ChildBg, kCard); - ImGui::BeginChild("logon", ImVec2(bw, bh), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar); - ImGui::PopStyleColor(); - ImGui::SetCursorPos(ImVec2(em() * 0.8f, (bh - em()) / 2 - 1)); - ImGui::TextUnformatted("Apply at logon"); - ImGui::SameLine(bw - em() * 3.4f); - ImGui::SetCursorPosY((bh - em() * 1.25f) / 2 - 1); - // Turning it off never needs a profile; turning it on does. - ImGui::BeginDisabled(!s.elevated || (!s.profile && !s.boot_on)); - if (toggle("##logon", s.boot_on)) act.set_boot(!s.boot_on); - ImGui::EndDisabled(); - ImGui::EndChild(); - ImGui::BeginDisabled(!s.elevated); - if (ImGui::Button(with_icon(kIconUndo, "Revert to stock").c_str(), ImVec2(bw, bh))) act.revert_to_stock(); - ImGui::EndDisabled(); - // The profiles are picked on the Optimize page; the dashboard only leads there. - if (ImGui::Button(with_icon(kIconPulse, "Optimize...").c_str(), ImVec2(bw, bh))) s.page = Page::Optimize; - ImGui::EndChild(); - end_card(); -} - -struct LogLine { - std::string time, text; - bool warn; -}; - -// boot.log lines and this session's notes, newest first. -std::vector log_lines(const UiState& s) { - std::vector out; - for (const std::string& line : s.boot_log) { - const size_t sep = line.find(" "); - LogLine l{sep == std::string::npos ? "" : line.substr(0, sep), sep == std::string::npos ? line : line.substr(sep + 2), - false}; - l.warn = l.text.find("not applied") != std::string::npos || l.text.find("failed") != std::string::npos || - l.text.find("stopped") != std::string::npos || l.text.find("another program") != std::string::npos; - out.push_back(std::move(l)); - } - for (const Note& n : s.notes) out.push_back({n.time, n.text, n.warn}); - // Same-minute entries keep their order: boot.log first, then notes. - std::stable_sort(out.begin(), out.end(), [](const LogLine& a, const LogLine& b) { return a.time > b.time; }); - return out; -} - -void log_card(const UiState& s) { - begin_card("log"); - heading("Log"); - const std::string more = with_icon(kIconList, g_full_log ? "Show recent" : "View full log"); - align_right(button_width(more)); - if (ImGui::Button(more.c_str())) g_full_log = !g_full_log; - const auto lines = log_lines(s); - if (lines.empty()) { - dim("Nothing yet."); - } else if (ImGui::BeginTable("log", 2, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerH | ImGuiTableFlags_BordersOuter | - ImGuiTableFlags_PadOuterX)) { - ImGui::TableSetupColumn("time", ImGuiTableColumnFlags_WidthFixed, em() * 8.5f); - ImGui::TableSetupColumn("text", ImGuiTableColumnFlags_WidthStretch); - const size_t n = g_full_log ? lines.size() : std::min(lines.size(), kLogShort); - for (size_t i = 0; i < n; ++i) { - ImGui::TableNextRow(ImGuiTableRowFlags_None, em() * 1.7f); - ImGui::TableNextColumn(); - dim(lines[i].time.c_str()); - ImGui::TableNextColumn(); - wrapped(lines[i].warn ? kWarn : kText, lines[i].text); - } - ImGui::EndTable(); - } - end_card(); -} - -constexpr FanPreset kFanPresets[] = {FanPreset::Silent, FanPreset::Normal, FanPreset::Cool, FanPreset::Aggressive}; -constexpr const char* kFanPresetTitles[] = {"Silent", "Normal", "Cool", "Aggressive"}; - -// One button per fan preset; `current` is highlighted. Returns the clicked one. -std::optional fan_preset_buttons(const char* id, const std::optional& current) { - std::optional clicked; - ImGui::PushID(id); - for (size_t i = 0; i < std::size(kFanPresets); ++i) { - if (i > 0) ImGui::SameLine(); - const bool on = current == kFanPresets[i]; - if (on) { - ImGui::PushStyleColor(ImGuiCol_Button, kSelectedBg); - ImGui::PushStyleColor(ImGuiCol_Border, kAccentBright); - } - if (ImGui::Button(kFanPresetTitles[i])) clicked = kFanPresets[i]; - if (on) ImGui::PopStyleColor(2); - } - ImGui::PopID(); - return clicked; -} - -// The preset whose curve equals c, if any. raised_c: by how much this card's -// limit is above the profile's; the presets are offered moved up by it. -std::optional preset_of(const FanCurve& c, int raised_c = 0) { - for (const FanPreset p : kFanPresets) - if (shifted_curve(fan_preset_curve(p), raised_c) == c) return p; - return std::nullopt; -} - -// Temperature 20-100 C on x, fan 0-100 % on y. Points drag with the mouse and -// stay ordered: a point cannot pass its neighbours in temperature, and fan % -// never goes down from one point to the next. Returns true when a drag ended, -// so the caller saves once per edit rather than every frame. -bool curve_editor(FanCurve& c, int min_pct, int max_temp_c, int now_temp_c, int* selected) { - const float w = ImGui::GetContentRegionAvail().x, h = em() * 16; - const ImVec2 o = ImGui::GetCursorScreenPos(); - ImDrawList* dl = ImGui::GetWindowDrawList(); - const float pad = em() * 2.2f; - const ImVec2 a(o.x + pad, o.y + em() * 0.5f), b(o.x + w - em() * 0.5f, o.y + h - pad); - auto to_screen = [&](float t, float p) { - return ImVec2(a.x + (t - 20) / 80 * (b.x - a.x), b.y - p / 100 * (b.y - a.y)); - }; - dl->AddRectFilled(a, b, ImGui::GetColorU32(kInner), em() * 0.3f); - for (int t = 20; t <= 100; t += 10) { - const ImVec2 p = to_screen(static_cast(t), 0); - dl->AddLine(ImVec2(p.x, a.y), ImVec2(p.x, b.y), ImGui::GetColorU32(kBorder)); - dl->AddText(ImVec2(p.x - em() * 0.6f, b.y + em() * 0.3f), ImGui::GetColorU32(kDim), (std::to_string(t) + "\xC2\xB0").c_str()); - } - for (int p = 0; p <= 100; p += 25) { - const ImVec2 q = to_screen(20, static_cast(p)); - dl->AddLine(ImVec2(a.x, q.y), ImVec2(b.x, q.y), ImGui::GetColorU32(kBorder)); - dl->AddText(ImVec2(o.x, q.y - em() * 0.5f), ImGui::GetColorU32(kDim), (std::to_string(p) + "%").c_str()); - } - // Fan-stop zone, the card's minimum, the profile's limit and the current temperature. - if (c.stop_below_c) { - const ImVec2 z = to_screen(static_cast(*c.stop_below_c), 0); - dl->AddRectFilled(a, ImVec2(z.x, b.y), ImGui::GetColorU32(ImVec4(kGood.x, kGood.y, kGood.z, 0.10f))); - } - const ImVec2 m = to_screen(20, static_cast(min_pct)); - dl->AddLine(ImVec2(a.x, m.y), ImVec2(b.x, m.y), ImGui::GetColorU32(kWarn), 1.0f); - const ImVec2 lim = to_screen(static_cast(max_temp_c), 100); - dl->AddLine(ImVec2(lim.x, a.y), ImVec2(lim.x, b.y), ImGui::GetColorU32(kBad), 1.5f); - if (now_temp_c >= 20) { - const ImVec2 n = to_screen(static_cast(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(pts.front().temp_c)) return static_cast(pts.front().pct); - for (size_t k = 1; k < pts.size(); ++k) { - if (t > static_cast(pts[k].temp_c)) continue; - const float f = (t - static_cast(pts[k - 1].temp_c)) / static_cast(pts[k].temp_c - pts[k - 1].temp_c); - return static_cast(pts[k - 1].pct) + f * static_cast(pts[k].pct - pts[k - 1].pct); - } - return static_cast(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(max_temp_c)) return 100.0f; - if (c.stop_below_c && t < static_cast(*c.stop_below_c)) return 0.0f; - return std::max(as_set(t), static_cast(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 set_line{to_screen(20, as_set(20))}; - for (const FanPoint& pt : c.points) set_line.push_back(to_screen(static_cast(pt.temp_c), static_cast(pt.pct))); - set_line.push_back(to_screen(100, as_set(100))); - dl->AddPolyline(set_line.data(), static_cast(set_line.size()), - ImGui::GetColorU32(ImVec4(kAccentBright.x, kAccentBright.y, kAccentBright.z, 0.40f)), ImDrawFlags_None, 1.2f); - std::vector line; - for (int i = 0; i <= 80 * 8; ++i) { - const float t = 20 + static_cast(i) / 8; - line.push_back(to_screen(t, effective(t))); - } - dl->AddPolyline(line.data(), static_cast(line.size()), ImGui::GetColorU32(kAccentBright), ImDrawFlags_None, 2.5f); - bool released = false; - for (int i = 0; i < static_cast(c.points.size()); ++i) { - FanPoint& pt = c.points[static_cast(i)]; - const ImVec2 p = to_screen(static_cast(pt.temp_c), static_cast(pt.pct)); - const float r = em() * 0.45f; - ImGui::SetCursorScreenPos(ImVec2(p.x - r * 1.5f, p.y - r * 1.5f)); - ImGui::PushID(i); - ImGui::InvisibleButton("pt", ImVec2(r * 3, r * 3)); - if (ImGui::IsItemActivated()) *selected = i; - if (ImGui::IsItemActive()) { - const ImVec2 mouse = ImGui::GetIO().MousePos; - const int lo_t = i > 0 ? c.points[static_cast(i) - 1].temp_c + 1 : 20; - const int hi_t = i + 1 < static_cast(c.points.size()) ? c.points[static_cast(i) + 1].temp_c - 1 : 100; - const int lo_p = i > 0 ? c.points[static_cast(i) - 1].pct : 0; - const int hi_p = i + 1 < static_cast(c.points.size()) ? c.points[static_cast(i) + 1].pct : 100; - pt.temp_c = std::clamp(static_cast(std::lround(20 + (mouse.x - a.x) / (b.x - a.x) * 80)), lo_t, hi_t); - pt.pct = std::clamp(static_cast(std::lround((b.y - mouse.y) / (b.y - a.y) * 100)), lo_p, hi_p); - } - if (ImGui::IsItemDeactivated()) released = true; - const bool hot = ImGui::IsItemHovered() || ImGui::IsItemActive() || *selected == i; - // A point a guard rail overrides is drawn hollow, off the line, with the reason. - const char* why = pt.temp_c >= max_temp_c ? "the profile's limit: the fans run at 100 % here" - : c.stop_below_c && pt.temp_c < *c.stop_below_c ? "the fan-stop zone: the driver has the fans here" - : pt.pct < min_pct ? "raised to the card's minimum" - : nullptr; - const ImU32 col = ImGui::GetColorU32(hot ? kText : kAccentBright); - if (why) { - dl->AddCircleFilled(p, r, ImGui::GetColorU32(kInner)); - dl->AddCircle(p, r, col, 0, 2.0f); - } else { - dl->AddCircleFilled(p, r, col); - } - if (ImGui::IsItemHovered() || ImGui::IsItemActive()) { - if (why) ImGui::SetTooltip("%d \xC2\xB0""C %d %%\nOverridden by %s", pt.temp_c, pt.pct, why); - else ImGui::SetTooltip("%d \xC2\xB0""C %d %%", pt.temp_c, pt.pct); - } - ImGui::PopID(); - } - ImGui::SetCursorScreenPos(ImVec2(o.x, o.y + h)); - ImGui::Dummy(ImVec2(w, 0)); - return released; -} - -void fan_page(UiState& s, const UiActions& act) { - static FanCurve edit; // the curve being edited; saved when a drag ends - static bool editing = false; - static int selected = -1; - begin_card("fan"); - heading("Fan curve"); - if (!s.fan_available) { - wrapped(kDim, s.elevated ? "This card's fans cannot be controlled through the NVIDIA driver." - : "Fan control needs administrator rights. Restart as administrator from the dashboard."); - end_card(); - return; - } - if (!s.fan_curve) { - wrapped(kDim, "Optimize first: the fan curve belongs to a saved tune."); - end_card(); - return; - } - if (!editing) edit = *s.fan_curve; - - const std::string label = "Fan control"; - ImGui::TextUnformatted(label.c_str()); - ImGui::SameLine(); - if (toggle("##fanon", s.fan_control)) act.set_fan_control(!s.fan_control); - ImGui::SameLine(0, em() * 2); - switch (s.fan_state.mode) { - case FanMode::Curve: dim(("Curve: " + std::to_string(s.fan_state.pct) + " %").c_str()); break; - case FanMode::Foreign: ImGui::TextColored(kWarn, "Another program controls the fans."); break; - case FanMode::Failed: ImGui::TextColored(kBad, "A fan speed did not verify; the driver controls the fans."); break; - case FanMode::Driver: dim(s.fan_control ? "The NVIDIA driver controls the fans right now." : "Off: the NVIDIA driver controls the fans."); break; - } - ImGui::Spacing(); - - const bool released = curve_editor(edit, s.fan_min_pct, s.fan_max_temp_c, s.telemetry.temp_c, &selected); - editing = ImGui::IsAnyItemActive(); - if (released && valid(edit)) act.set_fan_curve(edit); - - bool stop = edit.stop_below_c.has_value(); - if (ImGui::Checkbox("Stop the fans below", &stop)) { - edit.stop_below_c = stop ? std::optional(50) : std::nullopt; - act.set_fan_curve(edit); - } - if (stop) { - ImGui::SameLine(); - int v = *edit.stop_below_c; - ImGui::SetNextItemWidth(em() * 12); - ImGui::SliderInt("##stop", &v, 30, 70, "%d \xC2\xB0""C"); - edit.stop_below_c = v; - if (ImGui::IsItemDeactivatedAfterEdit()) act.set_fan_curve(edit); - } - ImGui::SameLine(0, em() * 2); - ImGui::BeginDisabled(edit.points.size() >= 6); - if (ImGui::Button("Add point")) { - // Halfway along the widest temperature gap. - size_t at = 1; - for (size_t i = 1; i < edit.points.size(); ++i) - if (edit.points[i].temp_c - edit.points[i - 1].temp_c > edit.points[at].temp_c - edit.points[at - 1].temp_c) at = i; - const FanPoint& l = edit.points[at - 1]; - const FanPoint& r = edit.points[at]; - edit.points.insert(edit.points.begin() + static_cast(at), FanPoint{(l.temp_c + r.temp_c) / 2, (l.pct + r.pct) / 2}); - if (valid(edit)) act.set_fan_curve(edit); - } - ImGui::EndDisabled(); - ImGui::SameLine(); - ImGui::BeginDisabled(edit.points.size() <= 2 || selected < 0 || selected >= static_cast(edit.points.size())); - if (ImGui::Button("Remove point")) { - edit.points.erase(edit.points.begin() + selected); - selected = -1; - act.set_fan_curve(edit); - } - ImGui::EndDisabled(); - ImGui::SameLine(); - if (ImGui::Button("Profile default")) { - selected = -1; - act.reset_fan_curve(); - } - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("Presets"); - ImGui::SameLine(0, em()); - if (const auto p = fan_preset_buttons("fan", preset_of(edit, s.fan_limit_raised_c))) { - selected = -1; - edit = shifted_curve(fan_preset_curve(*p), s.fan_limit_raised_c); - act.set_fan_curve(edit); - } - - ImGui::Spacing(); - if (s.fan_tested && quieter_than(edit, *s.fan_tested, s.fan_max_temp_c)) - wrapped(kWarn, "This curve is quieter than the one the tune was tested with, so the card runs warmer than during the " - "test. Optimize again to be sure the tune holds."); - wrapped(kDim, "Below the card's minimum (yellow line) a fan cannot run slower. The fans start at the stop threshold and stop " - "again only once the card is 8 \xC2\xB0""C cooler and idle (under a fifth of its power limit) for a minute " - "(longer if they had to restart soon after stopping); until then they run at the minimum, then the NVIDIA " - "driver has them. From the red line (the profile's temperature limit) the fans " - "always run at 100 %."); - if (s.fan_limit_raised_c > 0) - wrapped(kDim, "On this card the limit is " + std::to_string(s.fan_max_temp_c) + " \xC2\xB0""C, not the profile's " + - std::to_string(s.fan_max_temp_c - s.fan_limit_raised_c) + " \xC2\xB0""C: it reached that by itself at stock. " - "The presets are moved up by the difference."); - end_card(); -} - -void dashboard(UiState& s, const UiActions& act) { - gpu_card(s, act); - tuning_card(s, act); - log_card(s); -} - -// ------------------------------------------------------------------ optimize - -void plot(const char* label, const std::deque& h, float lo, float hi, const char* overlay, float w) { - std::vector v(h.begin(), h.end()); - if (v.empty()) v.push_back(0); - ImGui::PlotLines(label, v.data(), static_cast(v.size()), 0, overlay, lo, hi, ImVec2(w, em() * 5)); -} - -void choose(UiState& s, const UiActions& act) { - begin_card("choose"); - heading("Choose a profile"); - dim("Each profile searches for the highest stable settings and applies a safety margin."); - const bool undervolt = s.preset == Preset::Undervolt, all = s.preset == Preset::AllInOne; - ImGui::Spacing(); - preset_cards(s); - ImGui::Spacing(); - // The undervolt search keeps the fans on the curve in use; the - // all-in-one run measures its own. - ImGui::BeginDisabled(undervolt || all); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("Fan curve during the run"); - ImGui::SameLine(0, em()); - if (ImGui::RadioButton("Profile's own", !s.optimize_fan)) s.optimize_fan.reset(); - ImGui::SameLine(0, em()); - if (const auto p = fan_preset_buttons("run", s.optimize_fan)) s.optimize_fan = p; - ImGui::EndDisabled(); - ImGui::Spacing(); - const ImVec2 big(em() * 13, em() * 2.6f); - ImGui::PushFont(g_bold, base() * 1.15f); - const bool go = s.elevated ? primary_button(with_icon(kIconPlay, undervolt ? "Undervolt GPU" : "Optimize GPU"), big) - : primary_button("Restart as administrator", big); - ImGui::PopFont(); - if (go) s.elevated ? act.optimize(s.preset) : act.restart_elevated(); - ImGui::SameLine(0, em() * 1.5f); - ImGui::BeginGroup(); - ImGui::TextUnformatted((ic(kIconClock) + (all ? " About 40 minutes of full GPU load." - : undervolt ? " About 13 minutes of full GPU load." - : " About 15 minutes of full GPU load.")) - .c_str()); - dim("Abort stops at any time: the card goes back to stock, or to your saved tune when that was on it."); - dim("A setting that crashes the machine is never tried again."); - dim("Close games and other programs that use the graphics card first: they change what the run measures."); - ImGui::EndGroup(); - // One click is all of it: the result is kept at every logon without - // another one. Shown while that is still off, so that it can be declined. - if (s.elevated && !s.boot_on) { - ImGui::Spacing(); - ImGui::Checkbox("Keep the result applied at every logon", &s.keep_at_logon); - ImGui::SameLine(0, em()); - dim("Copies the app to Program Files and starts it in the tray at logon."); - } - end_card(); -} - -// The voltage/frequency curve a run is writing (UiState::run_curve) over the -// card's own (run_stock): higher means faster at that voltage. An overclock -// lifts the whole line; an undervolt lifts the part left of where the card -// ran and cuts the top off flat. The dot (live: during the run) is where the -// card runs now: the lowest voltage at which the curve reaches the clock it -// reports. -void curve_card(const UiState& s, const char* caption, bool live) { - if (s.run_curve.size() < 2) return; // no run yet, or this card or driver does not offer the curve - begin_card("curve"); - heading("Voltage/frequency curve"); - wrapped(kDim, caption); - const float w = ImGui::GetContentRegionAvail().x, h = em() * 13; - const ImVec2 p0 = ImGui::GetCursorScreenPos(); - ImGui::Dummy(ImVec2(w, h)); - ImDrawList* dl = ImGui::GetWindowDrawList(); - dl->AddRectFilled(p0, ImVec2(p0.x + w, p0.y + h), ImGui::GetColorU32(kInner), em() * 0.35f); - - float v_lo = s.run_curve.front().volt_uv / 1000.0f, v_hi = v_lo; - float f_lo = s.run_curve.front().freq_khz / 1000.0f, f_hi = f_lo; - for (const std::vector* curve : {&s.run_stock, &s.run_curve}) - for (const VfPoint& pt : *curve) { - v_lo = std::min(v_lo, pt.volt_uv / 1000.0f); - v_hi = std::max(v_hi, pt.volt_uv / 1000.0f); - f_lo = std::min(f_lo, pt.freq_khz / 1000.0f); - f_hi = std::max(f_hi, pt.freq_khz / 1000.0f); - } - if (v_hi <= v_lo || f_hi <= f_lo) { - end_card(); - return; - } - const float pad = (f_hi - f_lo) * 0.06f; - f_lo -= pad; - f_hi += pad; - // Room for the axis labels on the left and below. - const ImVec2 a(p0.x + em() * 4.6f, p0.y + em() * 0.7f), b(p0.x + w - em() * 1.0f, p0.y + h - em() * 1.9f); - auto at = [&](float mv, float mhz) { - return ImVec2(a.x + (mv - v_lo) / (v_hi - v_lo) * (b.x - a.x), b.y - (mhz - f_lo) / (f_hi - f_lo) * (b.y - a.y)); - }; - const ImU32 grid = ImGui::GetColorU32(kBorder), label = ImGui::GetColorU32(kDim); - char text[48]; - for (int mhz = (static_cast(f_lo) / 500 + 1) * 500; mhz < f_hi; mhz += 500) { - const float y = at(v_lo, static_cast(mhz)).y; - dl->AddLine(ImVec2(a.x, y), ImVec2(b.x, y), grid); - std::snprintf(text, sizeof(text), "%d MHz", mhz); - dl->AddText(ImVec2(a.x - ImGui::CalcTextSize(text).x - em() * 0.5f, y - em() * 0.5f), label, text); - } - for (int mv = (static_cast(v_lo) / 100 + 1) * 100; mv < v_hi; mv += 100) { - const float x = at(static_cast(mv), f_lo).x; - dl->AddLine(ImVec2(x, a.y), ImVec2(x, b.y), grid); - std::snprintf(text, sizeof(text), "%d mV", mv); - dl->AddText(ImVec2(x - ImGui::CalcTextSize(text).x / 2, b.y + em() * 0.35f), label, text); - } - auto line = [&](const std::vector& curve, const ImVec4& colour, float thickness) { - std::vector points; - for (const VfPoint& pt : curve) points.push_back(at(pt.volt_uv / 1000.0f, pt.freq_khz / 1000.0f)); - if (points.size() > 1) - dl->AddPolyline(points.data(), static_cast(points.size()), ImGui::GetColorU32(colour), ImDrawFlags_None, thickness); - }; - line(s.run_stock, kDim, em() * 0.09f); - line(s.run_curve, kAccentBright, em() * 0.16f); - - // Where the card is now. Nothing while it idles below the curve. - std::string now; - const int now_khz = s.telemetry.core_mhz * 1000; - if (live && now_khz >= s.run_curve.front().freq_khz - kVfToleranceKhz) { - for (const VfPoint& pt : s.run_curve) { - if (pt.freq_khz < now_khz - kVfToleranceKhz) continue; - const ImVec2 dot = at(pt.volt_uv / 1000.0f, pt.freq_khz / 1000.0f); - dl->AddCircleFilled(dot, em() * 0.38f, ImGui::GetColorU32(kGood)); - dl->AddCircle(dot, em() * 0.38f, ImGui::GetColorU32(kBg), 0, em() * 0.1f); - now = "Now: " + std::to_string(s.telemetry.core_mhz) + " MHz, which this curve reaches at " + - std::to_string(pt.volt_uv / 1000) + " mV"; - break; - } - } - // The legend, top left, where neither curve runs. - float y = a.y + em() * 0.2f; - auto legend = [&](const ImVec4& colour, const std::string& name, bool is_dot) { - const float x = a.x + em() * 0.8f; - if (is_dot) dl->AddCircleFilled(ImVec2(x + em() * 0.7f, y + em() * 0.55f), em() * 0.3f, ImGui::GetColorU32(colour)); - else dl->AddLine(ImVec2(x, y + em() * 0.55f), ImVec2(x + em() * 1.4f, y + em() * 0.55f), ImGui::GetColorU32(colour), em() * 0.16f); - dl->AddText(ImVec2(x + em() * 2.0f, y), ImGui::GetColorU32(kText), name.c_str()); - y += em() * 1.35f; - }; - legend(kAccentBright, "The curve on the card", false); - if (!s.run_stock.empty()) legend(kDim, "The card's own curve (stock)", false); - if (!now.empty()) legend(kGood, now, true); - end_card(); -} - -void run_screen(UiState& s, const OptimizeWorker::Snapshot& run, const UiActions& act) { - begin_card("run"); - text_bold(run.is_retest ? "Testing the saved tune" - : s.preset == Preset::Undervolt ? "Finding the undervolt" - : (std::string("Optimizing: ") + title_of(s.preset)).c_str(), - 1.4f); - if (run.running) { - const std::string abort = with_icon(kIconStop, "Abort"); - align_right(button_width(abort) + em()); - if (ImGui::Button(abort.c_str(), ImVec2(button_width(abort) + em(), 0))) act.abort(); - } - dim(run.is_retest ? "Five minutes of full load with the tune applied. Aborting stops the test; the card goes back to " - "stock, or to the saved tune when that was on it before." - : "Aborting stops the running probe; the card goes back to stock, or to the saved tune when that was " - "on it before the run."); - ImGui::Spacing(); - // The run looked at what else uses the card before it began, found - // something, and waits for what to do about it. - if (run.running && !run.busy_question.empty()) { - text_bold((ic(kIconInfo) + " Other programs are using the graphics card").c_str(), 1.1f); - wrapped(kText, busy_text(run.busy_question) + "."); - wrapped(kDim, "A run measures the card under full load. What these programs take from it makes every number lower and less " - "steady: the result, and its comparison with stock. Close them and check again, or go on as it is. " - "Nothing is closed for you."); - ImGui::Spacing(); - if (primary_button("Check again")) act.answer_busy(BusyAnswer::CheckAgain); - ImGui::SameLine(); - if (ImGui::Button("Go on anyway")) act.answer_busy(BusyAnswer::GoOn); - ImGui::SameLine(); - if (ImGui::Button("Cancel")) act.answer_busy(BusyAnswer::Stop); - ImGui::Spacing(); - } - // 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); - 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(left) / 60, static_cast(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(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); - ImGui::Spacing(); - const float w = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) / 2; - char overlay[64]; - std::snprintf(overlay, sizeof(overlay), "temperature %s", reading(s.telemetry.temp_c, " C").c_str()); - plot("##temp", s.temp_history, 30, 90, overlay, w); - ImGui::SameLine(); - std::snprintf(overlay, sizeof(overlay), "power %s W", reading(s.telemetry.power_w, "").c_str()); - plot("##power", s.power_history, 0, static_cast(std::max(s.telemetry.power_limit_w, 1)), overlay, w); - end_card(); - - curve_card(s, "What the run writes to the card, read back every second. Higher is faster at that voltage.", true); - - // What is left of the page, and never less than a few lines: a card that - // sizes itself has no height to hand on to the list inside it. - begin_card("probes", std::max(em() * 14, ImGui::GetContentRegionAvail().y)); - heading("Probes"); - ImGui::PushStyleColor(ImGuiCol_ChildBg, kInner); - ImGui::BeginChild("lines", ImVec2(0, ImGui::GetContentRegionAvail().y), ImGuiChildFlags_Borders); - ImGui::PopStyleColor(); - for (const std::string& line : run.log) ImGui::TextUnformatted(line.c_str()); - if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) ImGui::SetScrollHereY(1.0f); - ImGui::EndChild(); - end_card(); - if (!run.running && run.ended()) s.screen = Screen::Results; -} - -// A before/after table; `rows` fills it through `row(name, before, after)`. -// The two runs it compares differ in length, and it says so: a card is not -// heated through after half a minute, so the temperature and the fan speed -// of the short run read lower than they would on a warm card. -template -void comparison(const StabilityResult& before, const StabilityResult& after, Rows rows) { - if (!ImGui::BeginTable("cmp", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerH | ImGuiTableFlags_BordersOuter | - ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_PadOuterX)) - return; - ImGui::TableSetupColumn(""); - ImGui::TableSetupColumn(("Before (stock, " + duration_text(before.seconds) + ")").c_str()); - ImGui::TableSetupColumn(("After (" + duration_text(after.seconds) + ")").c_str()); - ImGui::TableHeadersRow(); - rows([](const char* name, const std::string& a, const std::string& b) { - ImGui::TableNextRow(ImGuiTableRowFlags_None, em() * 1.6f); - ImGui::TableNextColumn(); dim(name); - ImGui::TableNextColumn(); ImGui::TextUnformatted(a.c_str()); - ImGui::TableNextColumn(); ImGui::TextUnformatted(b.c_str()); - }); - ImGui::EndTable(); - if (before.verdict == Verdict::TooHot) - wrapped(kDim, "The stock run was stopped at " + reading(before.peak_temp_c, " \xC2\xB0""C") + " after " + duration_text(before.seconds) + - ": with the NVIDIA driver's own fan control (fans at " + reading(before.end_fan_pct, " %") + - ") the card gets that hot under full load. From there the run kept the fans on a curve."); - else if (after.seconds > 3 * before.seconds) - wrapped(kDim, "The stock run lasted " + duration_text(before.seconds) + - ", too short to heat the card through: its temperature and fan speed read lower than they would after " + - duration_text(after.seconds) + ". Score, clock and power compare directly."); -} - -std::string score_text(double score) { - char buf[32]; - std::snprintf(buf, sizeof(buf), "%.0f it/s", score); - return buf; -} -// "5582 it/s (-1.0 %)", or the same for watts. -std::string against(double before, double after, const char* unit) { - char buf[64]; - std::snprintf(buf, sizeof(buf), "%.0f %s (%+.1f %%)", after, unit, before > 0 ? (after / before - 1) * 100 : 0.0); - return buf; -} - -// What a finished run found. True when a result is applied. -bool optimize_result(const app::OptimizeOutcome& o, bool put_back) { - if (!o.ran) { - heading("Not started"); - wrapped(kBad, o.error); - return false; - } - if (!o.result.ok) { - heading("Not applied"); - wrapped(kBad, o.result.reason); - if (!put_back) - wrapped(kText, o.result.stock_restored ? "The card is back at stock." : "Reset to stock FAILED -- run `gao --reset`."); - return false; - } - const OptimizeResult& r = o.result; - heading("Done"); - char line[160]; - std::snprintf(line, sizeof(line), "Applied: power %d %%, core +%d MHz, memory +%d MHz", r.power_pct, r.core_mhz, r.mem_mhz); - wrapped(kGood, line); - std::snprintf(line, sizeof(line), "Confirmed edges: core +%d MHz, memory +%d MHz. The difference is the safety margin.", - r.core_confirmed, r.mem_confirmed); - wrapped(kDim, line); - ImGui::Spacing(); - const StabilityResult& before = o.stock; // five minutes, like the soak - comparison(before, r.soak, [&](auto row) { - row("Score", score_text(before.score), against(before.score, r.soak.score, "it/s")); - row("Core clock", reading(before.avg_core_mhz, " MHz"), reading(r.soak.avg_core_mhz, " MHz")); - row("Memory clock", reading(before.avg_mem_mhz, " MHz"), reading(r.soak.avg_mem_mhz, " MHz")); - row("Peak temperature", reading(before.peak_temp_c, " \xC2\xB0""C"), reading(r.soak.peak_temp_c, " \xC2\xB0""C")); - row("Fan speed at the end", reading(before.end_fan_pct, " %"), reading(r.soak.end_fan_pct, " %")); - row("Power", reading(before.avg_power_w, " W"), reading(r.soak.avg_power_w, " W")); - }); - return true; -} - -bool undervolt_result(const app::UndervoltOutcome& o, bool put_back) { - if (!o.ran) { - heading("Not started"); - wrapped(kBad, o.error); - return false; - } - if (!o.result.ok) { - heading("Not applied"); - wrapped(kBad, o.result.reason); - if (!put_back) - wrapped(kText, o.result.stock_restored ? "The card is back at stock." : "Reset to stock FAILED -- run `gao --reset`."); - return false; - } - const UndervoltResult& r = o.result; - heading("Done"); - char line[200]; - std::snprintf(line, sizeof(line), "Applied: %d MHz at %d mV instead of %d mV", r.freq_khz / 1000, r.applied_uv / 1000, - r.stock_uv / 1000); - wrapped(kGood, line); - std::snprintf(line, sizeof(line), "Lowest stable voltage: %d mV, confirmed %d mV. The difference is the safety margin.", - r.edge_uv / 1000, r.confirmed_uv / 1000); - wrapped(kDim, line); - ImGui::Spacing(); - comparison(r.baseline, r.after, [&](auto row) { - row("Score", score_text(r.baseline.score), against(r.baseline.score, r.after.score, "it/s")); - row("Core clock", reading(r.baseline.avg_core_mhz, " MHz"), reading(r.after.avg_core_mhz, " MHz")); - row("Voltage for that clock", std::to_string(r.stock_uv / 1000) + " mV", std::to_string(r.applied_uv / 1000) + " mV"); - row("Peak temperature", reading(r.baseline.peak_temp_c, " \xC2\xB0""C"), reading(r.after.peak_temp_c, " \xC2\xB0""C")); - row("Fan speed at the end", reading(r.baseline.end_fan_pct, " %"), reading(r.after.end_fan_pct, " %")); - row("Power", reading(r.baseline.avg_power_w, " W"), - r.baseline.avg_power_w > 0 && r.after.avg_power_w >= 0 ? against(r.baseline.avg_power_w, r.after.avg_power_w, "W") - : reading(r.after.avg_power_w, " W")); - }); - return true; -} - -// The all-in-one run: what the card runs now, next to what it ran at stock. -bool all_in_one_result(const app::AllInOneOutcome& o, bool put_back) { - if (!o.ok) { - heading(o.ran ? "Not applied" : "Not started"); - wrapped(kBad, o.error); - if (o.ran && !put_back) wrapped(kText, "The card is at stock."); - return false; - } - const Profile& p = o.profile; - heading("Done"); - wrapped(kGood, "Applied: core " + offset(p.core_mhz) + ", memory " + offset(p.mem_mhz) + ", power limit " + std::to_string(p.power_pct) + - " %" + (p.undervolt ? ", " + undervolt_text(*p.undervolt) : std::string())); - if (o.fan.ok) - wrapped(kGood, "Fans: " + std::to_string(o.fan.hold_pct) + " % keeps the card at " + std::to_string(o.fan.hold_temp_c) + - " \xC2\xB0""C under full load; the fan curve goes through that point."); - if (!o.undervolt_note.empty()) wrapped(kWarn, "No undervolt on top of the overclock: " + o.undervolt_note + "."); - if (!o.fan_note.empty()) wrapped(kWarn, "Fans: " + o.fan_note + "."); - if (!o.disturbed.empty()) wrapped(kWarn, "The numbers below are not clean: " + o.disturbed + "."); - ImGui::Spacing(); - comparison(o.stock, o.now, [&](auto row) { - row("Score", score_text(o.stock.score), against(o.stock.score, o.now.score, "it/s")); - row("Core clock", reading(o.stock.avg_core_mhz, " MHz"), reading(o.now.avg_core_mhz, " MHz")); - row("Memory clock", reading(o.stock.avg_mem_mhz, " MHz"), reading(o.now.avg_mem_mhz, " MHz")); - row("Peak temperature", reading(o.stock.peak_temp_c, " \xC2\xB0""C"), reading(o.now.peak_temp_c, " \xC2\xB0""C")); - row("Fan speed at the end", reading(o.stock.end_fan_pct, " %"), reading(o.now.end_fan_pct, " %")); - row("Power", reading(o.stock.avg_power_w, " W"), - o.stock.avg_power_w > 0 && o.now.avg_power_w >= 0 ? against(o.stock.avg_power_w, o.now.avg_power_w, "W") - : reading(o.now.avg_power_w, " W")); - }); - return true; -} - -// The saved tune after its five minutes on a new driver. True: it is applied. -bool retest_result(const app::RetestOutcome& o) { - if (!o.ok) { - heading("Not applied"); - wrapped(kBad, o.error); - if (o.result.test.seconds > 0) - wrapped(kText, "The saved tune is kept as it is and stays off on this driver. Optimize again to find one that holds."); - return false; - } - heading("The tune holds"); - wrapped(kGood, "Five minutes of full load on driver " + o.driver + " (it was tested on " + o.driver_was + - "): stable. It is applied again: core " + offset(o.profile.core_mhz) + ", memory " + offset(o.profile.mem_mhz) + - ", power limit " + std::to_string(o.profile.power_pct) + " %" + - (o.profile.undervolt ? ", " + undervolt_text(*o.profile.undervolt) : std::string())); - const StabilityResult& t = o.result.test; - wrapped(kDim, "Score " + score_text(t.score) + ", core " + reading(t.avg_core_mhz, " MHz") + ", memory " + - reading(t.avg_mem_mhz, " MHz") + ", peak " + reading(t.peak_temp_c, " \xC2\xB0""C") + ", fans " + - reading(t.end_fan_pct, " %") + " at the end, " + reading(t.avg_power_w, " W") + "."); - return true; -} - -// After a driver reset the run recovers, but Windows and the other programs -// may not: on an RTX 5070 the desktop stayed black but for this window -// (hardware check 99). "Nothing left to do" would not be true then. -std::string reset_note(int resets) { - return "The graphics driver was reset during this run" + - (resets > 1 ? " (" + std::to_string(resets) + " times)" : std::string()) + - ". The run recovered, but Windows and other programs may not have: if windows stay black or do not respond, " - "sign out and back in, or restart the computer."; -} - -void results(UiState& s, const OptimizeWorker::Snapshot& run, const UiActions& act) { - if (!run.ended()) { - s.screen = Screen::Choose; - return; - } - begin_card("results"); - const bool applied = run.retest ? retest_result(*run.retest) - : run.all_in_one ? all_in_one_result(*run.all_in_one, run.put_back) - : run.undervolt ? undervolt_result(*run.undervolt, run.put_back) - : optimize_result(*run.outcome, run.put_back); - // Stopped, and the saved tune that was on the card before the run is on it again (worker.cpp). - if (!applied && run.put_back) wrapped(kText, "Your saved tune is on the card again, as it was before the run."); - const int resets = run.all_in_one ? app::driver_resets(*run.all_in_one) - : run.undervolt ? app::driver_resets(*run.undervolt) - : run.outcome ? app::driver_resets(*run.outcome) - : 0; - if (!applied && resets > 0) wrapped(kWarn, reset_note(resets)); - if (applied) { - const bool saved = run.retest ? true : run.all_in_one ? run.all_in_one->ok : run.undervolt ? run.undervolt->saved : run.outcome->saved; - ImGui::Spacing(); - if (saved && s.boot_on) - wrapped(kText, resets > 0 ? "Saved, and applied at every logon from now on." - : "Saved, and applied at every logon from now on. There is nothing left to do."); - else if (saved) wrapped(kText, "Saved. It stays applied until reboot; turn on apply at logon to keep it."); - else wrapped(kWarn, "Not saved: " + (run.undervolt ? run.undervolt->save_note : run.outcome ? run.outcome->save_note : std::string())); - if (run.outcome && !run.outcome->busy_note.empty()) - wrapped(kWarn, run.outcome->busy_note + ". The numbers of this run and the ones at stock were not measured on the same card, " - "so the comparison can be off by several percent."); - if (resets > 0) wrapped(kWarn, reset_note(resets)); - if (saved && !s.boot_on && primary_button("Apply at every logon")) act.set_boot(true); - if (saved && !s.boot_on) ImGui::SameLine(); - if (ImGui::Button(with_icon(kIconUndo, "Revert to stock").c_str())) act.revert_to_stock(); - ImGui::SameLine(); - } - if (ImGui::Button(with_icon(kIconBack, "Back").c_str())) s.screen = Screen::Choose; - end_card(); - if (applied) curve_card(s, "As the run left it. Higher is faster at that voltage.", false); -} - -void optimize_page(UiState& s, const OptimizeWorker::Snapshot& run, const UiActions& act) { - if (run.running) s.screen = Screen::Run; - switch (s.screen) { - case Screen::Choose: choose(s, act); break; - case Screen::Run: run_screen(s, run, act); break; - case Screen::Results: results(s, run, act); break; - } -} - -// ------------------------------------------------------------------ about - -void about(const UiState& s, const UiActions& act) { - begin_card("about"); - text_bold("GPU Auto Optimizer", 1.6f); - dim(("Version " + std::string(kVersion)).c_str()); - ImGui::Spacing(); - wrapped(kText, "Finds the power limit and the core and memory clock offsets your NVIDIA card runs stably at, and keeps them " - "applied. Every setting it writes is read back and checked, a failed apply ends at stock, and a crash journal " - "makes sure a setting that froze the machine is never tried again."); - ImGui::Spacing(); - wrapped(kDim, "State: %ProgramData%\\GpuAutoOptimizer (gao.json, journal.jsonl, boot.log)"); - wrapped(kDim, "Command line: gao.exe --help"); - wrapped(kDim, "Source: github.com/Rovey/gpu-auto-optimizer. MIT License."); - ImGui::Spacing(); - ImGui::Spacing(); - if (toggle("##updates", s.update_check)) act.set_update_check(!s.update_check); - ImGui::SameLine(0, em() * 0.8f); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("Look for a new version when the app starts"); - wrapped(kDim, "One request to github.com at start and once a day; nothing else is sent. A new version is shown at the bottom " - "left and installed only when you click it."); - end_card(); -} // ------------------------------------------------------------------ frame @@ -1316,67 +67,6 @@ void sidebar(UiState& s, const UiActions& act) { } -void load_fonts(const std::string& fonts_dir) { - ImGuiIO& io = ImGui::GetIO(); - auto add_icons = [&] { - ImFontConfig cfg; - cfg.MergeMode = true; - cfg.GlyphOffset.y = 3.0f; // the icon fonts sit higher than Segoe UI's baseline - if (!io.Fonts->AddFontFromFileTTF((fonts_dir + "\\SegoeIcons.ttf").c_str(), 17.0f, &cfg)) - io.Fonts->AddFontFromFileTTF((fonts_dir + "\\segmdl2.ttf").c_str(), 17.0f, &cfg); // Windows 10 - }; - g_regular = fonts_dir.empty() ? nullptr : io.Fonts->AddFontFromFileTTF((fonts_dir + "\\segoeui.ttf").c_str(), 17.0f); - if (!g_regular) { - g_regular = g_bold = io.Fonts->AddFontDefault(); - return; - } - add_icons(); - g_bold = io.Fonts->AddFontFromFileTTF((fonts_dir + "\\seguisb.ttf").c_str(), 17.0f); - if (g_bold) add_icons(); - else g_bold = g_regular; -} - -void apply_style(float scale) { - ImGuiStyle& st = ImGui::GetStyle(); - st = ImGuiStyle(); - ImGui::StyleColorsDark(); - ImVec4* c = st.Colors; - c[ImGuiCol_WindowBg] = kBg; - c[ImGuiCol_ChildBg] = ImVec4(0, 0, 0, 0); - c[ImGuiCol_PopupBg] = kCard; - c[ImGuiCol_Border] = kBorder; - c[ImGuiCol_Text] = kText; - c[ImGuiCol_TextDisabled] = kDim; - c[ImGuiCol_FrameBg] = kInner; - c[ImGuiCol_FrameBgHovered] = kInnerHover; - c[ImGuiCol_FrameBgActive] = kInnerHover; - c[ImGuiCol_Button] = kInner; - c[ImGuiCol_ButtonHovered] = kInnerHover; - c[ImGuiCol_ButtonActive] = kSelectedBg; - c[ImGuiCol_CheckMark] = kAccentBright; - c[ImGuiCol_Header] = kInner; - c[ImGuiCol_HeaderHovered] = kInnerHover; - c[ImGuiCol_HeaderActive] = kSelectedBg; - c[ImGuiCol_TableHeaderBg] = kInner; - c[ImGuiCol_TableBorderStrong] = kBorder; - c[ImGuiCol_TableBorderLight] = kBorder; - c[ImGuiCol_TableRowBg] = kInner; - c[ImGuiCol_TableRowBgAlt] = ImVec4(0.100f, 0.114f, 0.137f, 1); - c[ImGuiCol_PlotLines] = kAccentBright; - c[ImGuiCol_ScrollbarBg] = ImVec4(0, 0, 0, 0); - st.WindowRounding = 0; - st.ChildRounding = 8; - st.FrameRounding = 6; - st.GrabRounding = 6; - st.FrameBorderSize = 1; - st.ChildBorderSize = 1; - st.FramePadding = ImVec2(12, 7); - st.ItemSpacing = ImVec2(12, 12); - st.CellPadding = ImVec2(10, 4); - st.ScaleAllSizes(scale); - st.FontScaleDpi = scale; -} - void push_history(std::deque& h, float v) { h.push_back(v); while (h.size() > kHistory) h.pop_front(); diff --git a/src/app/gui/ui_about.cpp b/src/app/gui/ui_about.cpp new file mode 100644 index 0000000..0299f4f --- /dev/null +++ b/src/app/gui/ui_about.cpp @@ -0,0 +1,31 @@ +#include "app/gui/ui_widgets.hpp" +#include "core/version.hpp" + +namespace gao::gui { + +// ------------------------------------------------------------------ about + +void about(const UiState& s, const UiActions& act) { + begin_card("about"); + text_bold("GPU Auto Optimizer", 1.6f); + dim(("Version " + std::string(kVersion)).c_str()); + ImGui::Spacing(); + wrapped(kText, "Finds the power limit and the core and memory clock offsets your NVIDIA card runs stably at, and keeps them " + "applied. Every setting it writes is read back and checked, a failed apply ends at stock, and a crash journal " + "makes sure a setting that froze the machine is never tried again."); + ImGui::Spacing(); + wrapped(kDim, "State: %ProgramData%\\GpuAutoOptimizer (gao.json, journal.jsonl, boot.log)"); + wrapped(kDim, "Command line: gao.exe --help"); + wrapped(kDim, "Source: github.com/Rovey/gpu-auto-optimizer. MIT License."); + ImGui::Spacing(); + ImGui::Spacing(); + if (toggle("##updates", s.update_check)) act.set_update_check(!s.update_check); + ImGui::SameLine(0, em() * 0.8f); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Look for a new version when the app starts"); + wrapped(kDim, "One request to github.com at start and once a day; nothing else is sent. A new version is shown at the bottom " + "left and installed only when you click it."); + end_card(); +} + +} diff --git a/src/app/gui/ui_dashboard.cpp b/src/app/gui/ui_dashboard.cpp new file mode 100644 index 0000000..41d6a2d --- /dev/null +++ b/src/app/gui/ui_dashboard.cpp @@ -0,0 +1,246 @@ +#include "app/gui/ui_widgets.hpp" +#include "core/boot.hpp" +#include "core/watchdog.hpp" +#include +#include +#include + +namespace gao::gui { + +namespace { + +constexpr size_t kLogShort = 6; // log lines on the dashboard before "View full log" + +bool g_full_log = false; + +// ------------------------------------------------------------------ dashboard + +void gpu_card(const UiState& s, const UiActions& act) { + begin_card("gpu"); + const float top = ImGui::GetCursorPosY(); + ImGui::BeginGroup(); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, em() * 0.15f)); + text_bold(s.gpu_name.empty() ? "No NVIDIA GPU found" : s.gpu_name.c_str(), 1.6f); + dim(("Driver " + (s.driver.empty() ? std::string("unknown") : s.driver)).c_str()); + ImGui::PopStyleVar(); + ImGui::EndGroup(); + const std::string detect = with_icon(kIconRefresh, "Detect GPU"); + align_right(button_width(detect)); + ImGui::SetCursorPosY(top); + if (ImGui::Button(detect.c_str())) act.detect_gpu(); + ImGui::Spacing(); + telemetry_tiles(s); + end_card(); +} + +void tuning_row(const char* label, const std::string& value, const ImVec4& color = kText) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + dim(label); + ImGui::TableNextColumn(); + ImGui::PushStyleColor(ImGuiCol_Text, color); + ImGui::PushTextWrapPos(0); + ImGui::TextUnformatted(value.c_str()); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); +} + +// "5922 -> 6067 it/s (+2.4 %)", drawn with an arrow: a stock value next to +// the tune's; the change in percent only where it says something (score, power). +std::string stock_to_tune(double stock, double tuned, const char* unit, bool percent) { + char buf[96]; + if (stock < 0 || tuned < 0) return "Not measured"; + if (percent && stock > 0) + std::snprintf(buf, sizeof(buf), "%.0f \xE2\x86\x92 %.0f%s (%+.1f %%)", stock, tuned, unit, (tuned / stock - 1) * 100); + else + std::snprintf(buf, sizeof(buf), "%.0f \xE2\x86\x92 %.0f%s", stock, tuned, unit); + return buf; +} + +void tuning_card(UiState& s, const UiActions& act) { + begin_card("tuning"); + heading("Current tuning"); + const float gap = ImGui::GetStyle().ItemSpacing.x; + // The rows first, and the card after them: a value that wraps takes more + // than one line, and a card that counted rows cut the last one off. + struct Row { + const char* label; + std::string value; + ImVec4 color; + }; + std::vector rows; + auto row = [&rows](const char* label, const std::string& value, const ImVec4& color = kText) { rows.push_back({label, value, color}); }; + { + if (s.applied) { + const AppliedState& a = *s.applied; + // With an undervolt saved the curve has its say too: the offsets read +0 under a flat top. + const bool undervolt = s.profile && s.profile->undervolt; + const bool stock = a.core_mhz == 0 && a.mem_mhz == 0 && a.power_pct >= 99 && a.power_pct <= 101 && + (!undervolt || s.curve == CurveState::Stock); + const bool ours = s.profile && tune_applied(*s.profile, a, s.curve); + if (ours) row("Applied now", std::string(title_of(s.profile->preset)) + " (the saved tune)", kGood); + else if (stock) row("Applied now", "Stock"); + else row("Applied now", "Set by another program", kWarn); + if (undervolt) + row("Undervolt", ours ? undervolt_text(*s.profile->undervolt) + : s.curve == CurveState::Stock ? "Not applied" + : s.curve ? "Not applied (the curve carries other offsets)" + : "Unknown (the curve could not be read)", + ours ? kText : kDim); + row("Core clock offset", offset(a.core_mhz)); + row("Memory clock offset", offset(a.mem_mhz)); + row("Power limit", std::to_string(a.power_pct) + " %"); + } else { + row("Applied now", "Unknown (the driver does not report it)", kDim); + } + if (s.profile) { + const Profile& p = *s.profile; + std::string tune = std::to_string(p.power_pct) + " %, core " + offset(p.core_mhz) + ", memory " + offset(p.mem_mhz); + if (p.undervolt) { // clocks and power are stock under an undervolt: name only what is not + tune = undervolt_text(*p.undervolt); + if (p.power_pct != 100) tune += ", " + std::to_string(p.power_pct) + " %"; + if (p.mem_mhz != 0) tune += ", memory " + offset(p.mem_mhz); + } + row("Saved tune", std::string(title_of(p.preset)) + ": " + tune + " (" + p.saved_at + ")"); + // What the run that saved it measured under full load, five minutes each. + if (const auto& m = p.against_stock; m && s.profile_gpu_ok) { + row("Speed vs stock", stock_to_tune(m->stock.score, m->tuned.score, " it/s", true)); + row("Power vs stock", stock_to_tune(m->stock.power_w, m->tuned.power_w, " W", true)); + row("Fans under load", stock_to_tune(m->stock.fan_pct, m->tuned.fan_pct, " %", false)); + row("Peak temperature", stock_to_tune(m->stock.peak_temp_c, m->tuned.peak_temp_c, " \xC2\xB0""C", false)); + } else if (s.profile_gpu_ok) { + row("Against stock", "Measured by the next run: this tune was saved by an older version.", kDim); + } + if (!s.profile_gpu_ok) + row("", "Tuned on " + std::string(s.profile_driver_ok ? "" : "driver " + p.driver + " and ") + + "another card. Optimize again before relying on it.", + kWarn); + else if (!s.profile_driver_ok) + row("", "The driver was updated: this tune was tested on " + p.driver + + (s.driver.empty() ? "" : ", and this is " + s.driver) + + ". It is not applied until it has been tested again.", + kWarn); + } else { + row("Saved tune", "None yet. Pick a profile and optimize.", kDim); + } + if (s.strikes > 0) + row("Crash strikes", std::to_string(s.strikes) + " of " + std::to_string(kMaxBootStrikes) + + " (apply at logon stops at " + std::to_string(kMaxBootStrikes) + ")", + kWarn); + } + const ImGuiStyle& style = ImGui::GetStyle(); + const float right_w = std::max(em() * 17, ImGui::GetContentRegionAvail().x * 0.36f); + const float left_w = ImGui::GetContentRegionAvail().x - right_w - gap; + const float label_w = em() * 10; + // What is left for a value: the card's padding and the table's cell padding off both columns. + const float value_w = std::max(em() * 8, left_w - em() * 0.9f * 2 - label_w - style.CellPadding.x * 4); + float rows_h = 0; + for (const Row& r : rows) + rows_h += std::max(em(), ImGui::CalcTextSize(r.value.c_str(), nullptr, false, value_w).y) + style.CellPadding.y * 2; + // Four buttons in the actions column, with the spacing between them and the column's padding. + const float buttons_h = em() * 2.3f * 4 + style.ItemSpacing.y * 3 + style.WindowPadding.y * 2; + const float h = std::max(buttons_h, rows_h + em() * 1.8f); + + begin_card("values", h, kInner, left_w); + if (ImGui::BeginTable("rows", 2, ImGuiTableFlags_SizingFixedFit)) { + ImGui::TableSetupColumn("label", ImGuiTableColumnFlags_WidthFixed, label_w); + ImGui::TableSetupColumn("value", ImGuiTableColumnFlags_WidthStretch); + for (const Row& r : rows) tuning_row(r.label, r.value, r.color); + ImGui::EndTable(); + } + end_card(); + + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_ChildBg, kInner); + ImGui::BeginChild("actions", ImVec2(0, h), ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding); + ImGui::PopStyleColor(); + const float bw = ImGui::GetContentRegionAvail().x, bh = em() * 2.3f; + if (s.elevated && s.profile && s.profile_gpu_ok && !s.profile_driver_ok && !s.driver.empty()) { + // After a driver update the tune is not applied as it is: five minutes show whether it still holds. + if (primary_button("Test the tune (5 min)", ImVec2(bw, bh))) act.retest(); + } else if (s.elevated) { + ImGui::BeginDisabled(!s.profile); + if (primary_button("Apply saved tune", ImVec2(bw, bh))) act.apply_profile(); + ImGui::EndDisabled(); + } else { + if (primary_button("Restart as administrator", ImVec2(bw, bh))) act.restart_elevated(); + } + // Apply at logon: a framed row with a switch. + ImGui::PushStyleColor(ImGuiCol_ChildBg, kCard); + ImGui::BeginChild("logon", ImVec2(bw, bh), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar); + ImGui::PopStyleColor(); + ImGui::SetCursorPos(ImVec2(em() * 0.8f, (bh - em()) / 2 - 1)); + ImGui::TextUnformatted("Apply at logon"); + ImGui::SameLine(bw - em() * 3.4f); + ImGui::SetCursorPosY((bh - em() * 1.25f) / 2 - 1); + // Turning it off never needs a profile; turning it on does. + ImGui::BeginDisabled(!s.elevated || (!s.profile && !s.boot_on)); + if (toggle("##logon", s.boot_on)) act.set_boot(!s.boot_on); + ImGui::EndDisabled(); + ImGui::EndChild(); + ImGui::BeginDisabled(!s.elevated); + if (ImGui::Button(with_icon(kIconUndo, "Revert to stock").c_str(), ImVec2(bw, bh))) act.revert_to_stock(); + ImGui::EndDisabled(); + // The profiles are picked on the Optimize page; the dashboard only leads there. + if (ImGui::Button(with_icon(kIconPulse, "Optimize...").c_str(), ImVec2(bw, bh))) s.page = Page::Optimize; + ImGui::EndChild(); + end_card(); +} + +struct LogLine { + std::string time, text; + bool warn; +}; + +// boot.log lines and this session's notes, newest first. +std::vector log_lines(const UiState& s) { + std::vector out; + for (const std::string& line : s.boot_log) { + const size_t sep = line.find(" "); + LogLine l{sep == std::string::npos ? "" : line.substr(0, sep), sep == std::string::npos ? line : line.substr(sep + 2), + false}; + l.warn = l.text.find("not applied") != std::string::npos || l.text.find("failed") != std::string::npos || + l.text.find("stopped") != std::string::npos || l.text.find("another program") != std::string::npos; + out.push_back(std::move(l)); + } + for (const Note& n : s.notes) out.push_back({n.time, n.text, n.warn}); + // Same-minute entries keep their order: boot.log first, then notes. + std::stable_sort(out.begin(), out.end(), [](const LogLine& a, const LogLine& b) { return a.time > b.time; }); + return out; +} + +void log_card(const UiState& s) { + begin_card("log"); + heading("Log"); + const std::string more = with_icon(kIconList, g_full_log ? "Show recent" : "View full log"); + align_right(button_width(more)); + if (ImGui::Button(more.c_str())) g_full_log = !g_full_log; + const auto lines = log_lines(s); + if (lines.empty()) { + dim("Nothing yet."); + } else if (ImGui::BeginTable("log", 2, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerH | ImGuiTableFlags_BordersOuter | + ImGuiTableFlags_PadOuterX)) { + ImGui::TableSetupColumn("time", ImGuiTableColumnFlags_WidthFixed, em() * 8.5f); + ImGui::TableSetupColumn("text", ImGuiTableColumnFlags_WidthStretch); + const size_t n = g_full_log ? lines.size() : std::min(lines.size(), kLogShort); + for (size_t i = 0; i < n; ++i) { + ImGui::TableNextRow(ImGuiTableRowFlags_None, em() * 1.7f); + ImGui::TableNextColumn(); + dim(lines[i].time.c_str()); + ImGui::TableNextColumn(); + wrapped(lines[i].warn ? kWarn : kText, lines[i].text); + } + ImGui::EndTable(); + } + end_card(); +} + +} + +void dashboard(UiState& s, const UiActions& act) { + gpu_card(s, act); + tuning_card(s, act); + log_card(s); +} + +} diff --git a/src/app/gui/ui_fan.cpp b/src/app/gui/ui_fan.cpp new file mode 100644 index 0000000..22ab700 --- /dev/null +++ b/src/app/gui/ui_fan.cpp @@ -0,0 +1,233 @@ +#include "app/gui/ui_widgets.hpp" +#include +#include +#include + +namespace gao::gui { + +namespace { + +// The preset whose curve equals c, if any. raised_c: by how much this card's +// limit is above the profile's; the presets are offered moved up by it. +std::optional preset_of(const FanCurve& c, int raised_c = 0) { + for (const FanPreset p : kFanPresets) + if (shifted_curve(fan_preset_curve(p), raised_c) == c) return p; + return std::nullopt; +} + +// Temperature 20-100 C on x, fan 0-100 % on y. Points drag with the mouse and +// stay ordered: a point cannot pass its neighbours in temperature, and fan % +// never goes down from one point to the next. Returns true when a drag ended, +// so the caller saves once per edit rather than every frame. +bool curve_editor(FanCurve& c, int min_pct, int max_temp_c, int now_temp_c, int* selected) { + const float w = ImGui::GetContentRegionAvail().x, h = em() * 16; + const ImVec2 o = ImGui::GetCursorScreenPos(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + const float pad = em() * 2.2f; + const ImVec2 a(o.x + pad, o.y + em() * 0.5f), b(o.x + w - em() * 0.5f, o.y + h - pad); + auto to_screen = [&](float t, float p) { + return ImVec2(a.x + (t - 20) / 80 * (b.x - a.x), b.y - p / 100 * (b.y - a.y)); + }; + dl->AddRectFilled(a, b, ImGui::GetColorU32(kInner), em() * 0.3f); + for (int t = 20; t <= 100; t += 10) { + const ImVec2 p = to_screen(static_cast(t), 0); + dl->AddLine(ImVec2(p.x, a.y), ImVec2(p.x, b.y), ImGui::GetColorU32(kBorder)); + dl->AddText(ImVec2(p.x - em() * 0.6f, b.y + em() * 0.3f), ImGui::GetColorU32(kDim), (std::to_string(t) + "\xC2\xB0").c_str()); + } + for (int p = 0; p <= 100; p += 25) { + const ImVec2 q = to_screen(20, static_cast(p)); + dl->AddLine(ImVec2(a.x, q.y), ImVec2(b.x, q.y), ImGui::GetColorU32(kBorder)); + dl->AddText(ImVec2(o.x, q.y - em() * 0.5f), ImGui::GetColorU32(kDim), (std::to_string(p) + "%").c_str()); + } + // Fan-stop zone, the card's minimum, the profile's limit and the current temperature. + if (c.stop_below_c) { + const ImVec2 z = to_screen(static_cast(*c.stop_below_c), 0); + dl->AddRectFilled(a, ImVec2(z.x, b.y), ImGui::GetColorU32(ImVec4(kGood.x, kGood.y, kGood.z, 0.10f))); + } + const ImVec2 m = to_screen(20, static_cast(min_pct)); + dl->AddLine(ImVec2(a.x, m.y), ImVec2(b.x, m.y), ImGui::GetColorU32(kWarn), 1.0f); + const ImVec2 lim = to_screen(static_cast(max_temp_c), 100); + dl->AddLine(ImVec2(lim.x, a.y), ImVec2(lim.x, b.y), ImGui::GetColorU32(kBad), 1.5f); + if (now_temp_c >= 20) { + const ImVec2 n = to_screen(static_cast(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(pts.front().temp_c)) return static_cast(pts.front().pct); + for (size_t k = 1; k < pts.size(); ++k) { + if (t > static_cast(pts[k].temp_c)) continue; + const float f = (t - static_cast(pts[k - 1].temp_c)) / static_cast(pts[k].temp_c - pts[k - 1].temp_c); + return static_cast(pts[k - 1].pct) + f * static_cast(pts[k].pct - pts[k - 1].pct); + } + return static_cast(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(max_temp_c)) return 100.0f; + if (c.stop_below_c && t < static_cast(*c.stop_below_c)) return 0.0f; + return std::max(as_set(t), static_cast(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 set_line{to_screen(20, as_set(20))}; + for (const FanPoint& pt : c.points) set_line.push_back(to_screen(static_cast(pt.temp_c), static_cast(pt.pct))); + set_line.push_back(to_screen(100, as_set(100))); + dl->AddPolyline(set_line.data(), static_cast(set_line.size()), + ImGui::GetColorU32(ImVec4(kAccentBright.x, kAccentBright.y, kAccentBright.z, 0.40f)), ImDrawFlags_None, 1.2f); + std::vector line; + for (int i = 0; i <= 80 * 8; ++i) { + const float t = 20 + static_cast(i) / 8; + line.push_back(to_screen(t, effective(t))); + } + dl->AddPolyline(line.data(), static_cast(line.size()), ImGui::GetColorU32(kAccentBright), ImDrawFlags_None, 2.5f); + bool released = false; + for (int i = 0; i < static_cast(c.points.size()); ++i) { + FanPoint& pt = c.points[static_cast(i)]; + const ImVec2 p = to_screen(static_cast(pt.temp_c), static_cast(pt.pct)); + const float r = em() * 0.45f; + ImGui::SetCursorScreenPos(ImVec2(p.x - r * 1.5f, p.y - r * 1.5f)); + ImGui::PushID(i); + ImGui::InvisibleButton("pt", ImVec2(r * 3, r * 3)); + if (ImGui::IsItemActivated()) *selected = i; + if (ImGui::IsItemActive()) { + const ImVec2 mouse = ImGui::GetIO().MousePos; + const int lo_t = i > 0 ? c.points[static_cast(i) - 1].temp_c + 1 : 20; + const int hi_t = i + 1 < static_cast(c.points.size()) ? c.points[static_cast(i) + 1].temp_c - 1 : 100; + const int lo_p = i > 0 ? c.points[static_cast(i) - 1].pct : 0; + const int hi_p = i + 1 < static_cast(c.points.size()) ? c.points[static_cast(i) + 1].pct : 100; + pt.temp_c = std::clamp(static_cast(std::lround(20 + (mouse.x - a.x) / (b.x - a.x) * 80)), lo_t, hi_t); + pt.pct = std::clamp(static_cast(std::lround((b.y - mouse.y) / (b.y - a.y) * 100)), lo_p, hi_p); + } + if (ImGui::IsItemDeactivated()) released = true; + const bool hot = ImGui::IsItemHovered() || ImGui::IsItemActive() || *selected == i; + // A point a guard rail overrides is drawn hollow, off the line, with the reason. + const char* why = pt.temp_c >= max_temp_c ? "the profile's limit: the fans run at 100 % here" + : c.stop_below_c && pt.temp_c < *c.stop_below_c ? "the fan-stop zone: the driver has the fans here" + : pt.pct < min_pct ? "raised to the card's minimum" + : nullptr; + const ImU32 col = ImGui::GetColorU32(hot ? kText : kAccentBright); + if (why) { + dl->AddCircleFilled(p, r, ImGui::GetColorU32(kInner)); + dl->AddCircle(p, r, col, 0, 2.0f); + } else { + dl->AddCircleFilled(p, r, col); + } + if (ImGui::IsItemHovered() || ImGui::IsItemActive()) { + if (why) ImGui::SetTooltip("%d \xC2\xB0""C %d %%\nOverridden by %s", pt.temp_c, pt.pct, why); + else ImGui::SetTooltip("%d \xC2\xB0""C %d %%", pt.temp_c, pt.pct); + } + ImGui::PopID(); + } + ImGui::SetCursorScreenPos(ImVec2(o.x, o.y + h)); + ImGui::Dummy(ImVec2(w, 0)); + return released; +} + +} + +void fan_page(UiState& s, const UiActions& act) { + static FanCurve edit; // the curve being edited; saved when a drag ends + static bool editing = false; + static int selected = -1; + begin_card("fan"); + heading("Fan curve"); + if (!s.fan_available) { + wrapped(kDim, s.elevated ? "This card's fans cannot be controlled through the NVIDIA driver." + : "Fan control needs administrator rights. Restart as administrator from the dashboard."); + end_card(); + return; + } + if (!s.fan_curve) { + wrapped(kDim, "Optimize first: the fan curve belongs to a saved tune."); + end_card(); + return; + } + if (!editing) edit = *s.fan_curve; + + const std::string label = "Fan control"; + ImGui::TextUnformatted(label.c_str()); + ImGui::SameLine(); + if (toggle("##fanon", s.fan_control)) act.set_fan_control(!s.fan_control); + ImGui::SameLine(0, em() * 2); + switch (s.fan_state.mode) { + case FanMode::Curve: dim(("Curve: " + std::to_string(s.fan_state.pct) + " %").c_str()); break; + case FanMode::Foreign: ImGui::TextColored(kWarn, "Another program controls the fans."); break; + case FanMode::Failed: ImGui::TextColored(kBad, "A fan speed did not verify; the driver controls the fans."); break; + case FanMode::Driver: dim(s.fan_control ? "The NVIDIA driver controls the fans right now." : "Off: the NVIDIA driver controls the fans."); break; + } + ImGui::Spacing(); + + const bool released = curve_editor(edit, s.fan_min_pct, s.fan_max_temp_c, s.telemetry.temp_c, &selected); + editing = ImGui::IsAnyItemActive(); + if (released && valid(edit)) act.set_fan_curve(edit); + + bool stop = edit.stop_below_c.has_value(); + if (ImGui::Checkbox("Stop the fans below", &stop)) { + edit.stop_below_c = stop ? std::optional(50) : std::nullopt; + act.set_fan_curve(edit); + } + if (stop) { + ImGui::SameLine(); + int v = *edit.stop_below_c; + ImGui::SetNextItemWidth(em() * 12); + ImGui::SliderInt("##stop", &v, 30, 70, "%d \xC2\xB0""C"); + edit.stop_below_c = v; + if (ImGui::IsItemDeactivatedAfterEdit()) act.set_fan_curve(edit); + } + ImGui::SameLine(0, em() * 2); + ImGui::BeginDisabled(edit.points.size() >= 6); + if (ImGui::Button("Add point")) { + // Halfway along the widest temperature gap. + size_t at = 1; + for (size_t i = 1; i < edit.points.size(); ++i) + if (edit.points[i].temp_c - edit.points[i - 1].temp_c > edit.points[at].temp_c - edit.points[at - 1].temp_c) at = i; + const FanPoint& l = edit.points[at - 1]; + const FanPoint& r = edit.points[at]; + edit.points.insert(edit.points.begin() + static_cast(at), FanPoint{(l.temp_c + r.temp_c) / 2, (l.pct + r.pct) / 2}); + if (valid(edit)) act.set_fan_curve(edit); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(edit.points.size() <= 2 || selected < 0 || selected >= static_cast(edit.points.size())); + if (ImGui::Button("Remove point")) { + edit.points.erase(edit.points.begin() + selected); + selected = -1; + act.set_fan_curve(edit); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button("Profile default")) { + selected = -1; + act.reset_fan_curve(); + } + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Presets"); + ImGui::SameLine(0, em()); + if (const auto p = fan_preset_buttons("fan", preset_of(edit, s.fan_limit_raised_c))) { + selected = -1; + edit = shifted_curve(fan_preset_curve(*p), s.fan_limit_raised_c); + act.set_fan_curve(edit); + } + + ImGui::Spacing(); + if (s.fan_tested && quieter_than(edit, *s.fan_tested, s.fan_max_temp_c)) + wrapped(kWarn, "This curve is quieter than the one the tune was tested with, so the card runs warmer than during the " + "test. Optimize again to be sure the tune holds."); + wrapped(kDim, "Below the card's minimum (yellow line) a fan cannot run slower. The fans start at the stop threshold and stop " + "again only once the card is 8 \xC2\xB0""C cooler and idle (under a fifth of its power limit) for a minute " + "(longer if they had to restart soon after stopping); until then they run at the minimum, then the NVIDIA " + "driver has them. From the red line (the profile's temperature limit) the fans " + "always run at 100 %."); + if (s.fan_limit_raised_c > 0) + wrapped(kDim, "On this card the limit is " + std::to_string(s.fan_max_temp_c) + " \xC2\xB0""C, not the profile's " + + std::to_string(s.fan_max_temp_c - s.fan_limit_raised_c) + " \xC2\xB0""C: it reached that by itself at stock. " + "The presets are moved up by the difference."); + end_card(); +} + +} diff --git a/src/app/gui/ui_optimize.cpp b/src/app/gui/ui_optimize.cpp new file mode 100644 index 0000000..6103ee4 --- /dev/null +++ b/src/app/gui/ui_optimize.cpp @@ -0,0 +1,313 @@ +#include "app/gui/ui_widgets.hpp" +#include "core/objectives.hpp" +#include +#include +#include +#include + +namespace gao::gui { + +// ------------------------------------------------------------------ optimize + +namespace { + +// An icon in a square badge, centred by the glyph's own outline and not by +// its advance: the glyphs of the icon font differ in width and in where +// they sit in their cell, which next to a title reads as uneven spacing. +void icon_badge(Icon icon, float side, const ImVec4& fill, const ImVec4& edge, const ImVec4& ink) { + const ImVec2 at = ImGui::GetCursorScreenPos(); + ImGui::Dummy(ImVec2(side, side)); + ImDrawList* dl = ImGui::GetWindowDrawList(); + const ImVec2 corner(at.x + side, at.y + side); + dl->AddRectFilled(at, corner, ImGui::GetColorU32(fill), side * 0.28f); + dl->AddRect(at, corner, ImGui::GetColorU32(edge), side * 0.28f); + ImGui::PushFont(nullptr, side * 0.5f); + ImVec2 pen(at.x + side * 0.25f, at.y + side * 0.25f); // a glyph the font does not have: roughly centred + if (const ImFontGlyph* glyph = ImGui::GetFontBaked()->FindGlyph(static_cast(icon))) + pen = ImVec2(at.x + (side - glyph->X0 - glyph->X1) / 2, at.y + (side - glyph->Y0 - glyph->Y1) / 2); + dl->AddText(ImVec2(std::floor(pen.x + 0.5f), std::floor(pen.y + 0.5f)), ImGui::GetColorU32(ink), ic(icon).c_str()); + ImGui::PopFont(); +} + +// One profile as a card; clicking it selects it. h 0: as tall as its contents. +// The selected card says so with its border and its badge; there is no +// radio button next to them saying it a third time. +void preset_card(UiState& s, const PresetInfo& p, float w, float h) { + ImGui::PushID(static_cast(p.preset)); + const bool selected = s.preset == p.preset; + ImGui::PushStyleColor(ImGuiCol_ChildBg, selected ? kSelectedBg : kInner); + ImGui::PushStyleColor(ImGuiCol_Border, selected ? kAccentBright : kBorder); + ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, selected ? 2.0f : 1.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(em() * 0.8f, em() * 0.7f)); + ImGui::BeginChild("preset", ImVec2(w, h), + ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding | + (h == 0 ? ImGuiChildFlags_AutoResizeY : ImGuiChildFlags_None), + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(2); + const float badge = em() * 2.1f; + icon_badge(p.icon, badge, selected ? kAccent : kCard, selected ? kAccentBright : kBorder, selected ? kText : kDim); + ImGui::SameLine(0, em() * 0.7f); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (badge - em() * 1.05f) / 2 - em() * 0.08f); // level with the badge + text_bold(p.title, 1.05f); + wrapped(kDim, std::string(p.summary) + " Up to " + std::to_string(objectives_for(p.preset).max_temp_c) + + " \xC2\xB0""C."); + ImGui::EndChild(); + if (ImGui::IsItemClicked()) s.preset = p.preset; + ImGui::PopID(); +} + +// All in one first, across the full width; the four clock profiles side by +// side; and the undervolt below them, the other kind of tune. +void preset_cards(UiState& s) { + preset_card(s, kAllInOne, ImGui::GetContentRegionAvail().x, 0); + const float gap = ImGui::GetStyle().ItemSpacing.x; + const float w = (ImGui::GetContentRegionAvail().x - gap * 3) / 4; + for (size_t i = 0; i < std::size(kPresets); ++i) { + preset_card(s, kPresets[i], w, em() * 8.3f); + if (i + 1 < std::size(kPresets)) ImGui::SameLine(); + } + preset_card(s, kUndervolt, ImGui::GetContentRegionAvail().x, 0); // as tall as its text, at any width +} + +void plot(const char* label, const std::deque& h, float lo, float hi, const char* overlay, float w) { + std::vector v(h.begin(), h.end()); + if (v.empty()) v.push_back(0); + ImGui::PlotLines(label, v.data(), static_cast(v.size()), 0, overlay, lo, hi, ImVec2(w, em() * 5)); +} + +void choose(UiState& s, const UiActions& act) { + begin_card("choose"); + heading("Choose a profile"); + dim("Each profile searches for the highest stable settings and applies a safety margin."); + const bool undervolt = s.preset == Preset::Undervolt, all = s.preset == Preset::AllInOne; + ImGui::Spacing(); + preset_cards(s); + ImGui::Spacing(); + // The undervolt search keeps the fans on the curve in use; the + // all-in-one run measures its own. + ImGui::BeginDisabled(undervolt || all); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Fan curve during the run"); + ImGui::SameLine(0, em()); + if (ImGui::RadioButton("Profile's own", !s.optimize_fan)) s.optimize_fan.reset(); + ImGui::SameLine(0, em()); + if (const auto p = fan_preset_buttons("run", s.optimize_fan)) s.optimize_fan = p; + ImGui::EndDisabled(); + ImGui::Spacing(); + const ImVec2 big(em() * 13, em() * 2.6f); + ImGui::PushFont(g_bold, base() * 1.15f); + const bool go = s.elevated ? primary_button(with_icon(kIconPlay, undervolt ? "Undervolt GPU" : "Optimize GPU"), big) + : primary_button("Restart as administrator", big); + ImGui::PopFont(); + if (go) s.elevated ? act.optimize(s.preset) : act.restart_elevated(); + ImGui::SameLine(0, em() * 1.5f); + ImGui::BeginGroup(); + ImGui::TextUnformatted((ic(kIconClock) + (all ? " About 40 minutes of full GPU load." + : undervolt ? " About 13 minutes of full GPU load." + : " About 15 minutes of full GPU load.")) + .c_str()); + dim("Abort stops at any time: the card goes back to stock, or to your saved tune when that was on it."); + dim("A setting that crashes the machine is never tried again."); + dim("Close games and other programs that use the graphics card first: they change what the run measures."); + ImGui::EndGroup(); + // One click is all of it: the result is kept at every logon without + // another one. Shown while that is still off, so that it can be declined. + if (s.elevated && !s.boot_on) { + ImGui::Spacing(); + ImGui::Checkbox("Keep the result applied at every logon", &s.keep_at_logon); + ImGui::SameLine(0, em()); + dim("Copies the app to Program Files and starts it in the tray at logon."); + } + end_card(); +} + +} + +// The voltage/frequency curve a run is writing (UiState::run_curve) over the +// card's own (run_stock): higher means faster at that voltage. An overclock +// lifts the whole line; an undervolt lifts the part left of where the card +// ran and cuts the top off flat. The dot (live: during the run) is where the +// card runs now: the lowest voltage at which the curve reaches the clock it +// reports. +void curve_card(const UiState& s, const char* caption, bool live) { + if (s.run_curve.size() < 2) return; // no run yet, or this card or driver does not offer the curve + begin_card("curve"); + heading("Voltage/frequency curve"); + wrapped(kDim, caption); + const float w = ImGui::GetContentRegionAvail().x, h = em() * 13; + const ImVec2 p0 = ImGui::GetCursorScreenPos(); + ImGui::Dummy(ImVec2(w, h)); + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddRectFilled(p0, ImVec2(p0.x + w, p0.y + h), ImGui::GetColorU32(kInner), em() * 0.35f); + + float v_lo = s.run_curve.front().volt_uv / 1000.0f, v_hi = v_lo; + float f_lo = s.run_curve.front().freq_khz / 1000.0f, f_hi = f_lo; + for (const std::vector* curve : {&s.run_stock, &s.run_curve}) + for (const VfPoint& pt : *curve) { + v_lo = std::min(v_lo, pt.volt_uv / 1000.0f); + v_hi = std::max(v_hi, pt.volt_uv / 1000.0f); + f_lo = std::min(f_lo, pt.freq_khz / 1000.0f); + f_hi = std::max(f_hi, pt.freq_khz / 1000.0f); + } + if (v_hi <= v_lo || f_hi <= f_lo) { + end_card(); + return; + } + const float pad = (f_hi - f_lo) * 0.06f; + f_lo -= pad; + f_hi += pad; + // Room for the axis labels on the left and below. + const ImVec2 a(p0.x + em() * 4.6f, p0.y + em() * 0.7f), b(p0.x + w - em() * 1.0f, p0.y + h - em() * 1.9f); + auto at = [&](float mv, float mhz) { + return ImVec2(a.x + (mv - v_lo) / (v_hi - v_lo) * (b.x - a.x), b.y - (mhz - f_lo) / (f_hi - f_lo) * (b.y - a.y)); + }; + const ImU32 grid = ImGui::GetColorU32(kBorder), label = ImGui::GetColorU32(kDim); + char text[48]; + for (int mhz = (static_cast(f_lo) / 500 + 1) * 500; mhz < f_hi; mhz += 500) { + const float y = at(v_lo, static_cast(mhz)).y; + dl->AddLine(ImVec2(a.x, y), ImVec2(b.x, y), grid); + std::snprintf(text, sizeof(text), "%d MHz", mhz); + dl->AddText(ImVec2(a.x - ImGui::CalcTextSize(text).x - em() * 0.5f, y - em() * 0.5f), label, text); + } + for (int mv = (static_cast(v_lo) / 100 + 1) * 100; mv < v_hi; mv += 100) { + const float x = at(static_cast(mv), f_lo).x; + dl->AddLine(ImVec2(x, a.y), ImVec2(x, b.y), grid); + std::snprintf(text, sizeof(text), "%d mV", mv); + dl->AddText(ImVec2(x - ImGui::CalcTextSize(text).x / 2, b.y + em() * 0.35f), label, text); + } + auto line = [&](const std::vector& curve, const ImVec4& colour, float thickness) { + std::vector points; + for (const VfPoint& pt : curve) points.push_back(at(pt.volt_uv / 1000.0f, pt.freq_khz / 1000.0f)); + if (points.size() > 1) + dl->AddPolyline(points.data(), static_cast(points.size()), ImGui::GetColorU32(colour), ImDrawFlags_None, thickness); + }; + line(s.run_stock, kDim, em() * 0.09f); + line(s.run_curve, kAccentBright, em() * 0.16f); + + // Where the card is now. Nothing while it idles below the curve. + std::string now; + const int now_khz = s.telemetry.core_mhz * 1000; + if (live && now_khz >= s.run_curve.front().freq_khz - kVfToleranceKhz) { + for (const VfPoint& pt : s.run_curve) { + if (pt.freq_khz < now_khz - kVfToleranceKhz) continue; + const ImVec2 dot = at(pt.volt_uv / 1000.0f, pt.freq_khz / 1000.0f); + dl->AddCircleFilled(dot, em() * 0.38f, ImGui::GetColorU32(kGood)); + dl->AddCircle(dot, em() * 0.38f, ImGui::GetColorU32(kBg), 0, em() * 0.1f); + now = "Now: " + std::to_string(s.telemetry.core_mhz) + " MHz, which this curve reaches at " + + std::to_string(pt.volt_uv / 1000) + " mV"; + break; + } + } + // The legend, top left, where neither curve runs. + float y = a.y + em() * 0.2f; + auto legend = [&](const ImVec4& colour, const std::string& name, bool is_dot) { + const float x = a.x + em() * 0.8f; + if (is_dot) dl->AddCircleFilled(ImVec2(x + em() * 0.7f, y + em() * 0.55f), em() * 0.3f, ImGui::GetColorU32(colour)); + else dl->AddLine(ImVec2(x, y + em() * 0.55f), ImVec2(x + em() * 1.4f, y + em() * 0.55f), ImGui::GetColorU32(colour), em() * 0.16f); + dl->AddText(ImVec2(x + em() * 2.0f, y), ImGui::GetColorU32(kText), name.c_str()); + y += em() * 1.35f; + }; + legend(kAccentBright, "The curve on the card", false); + if (!s.run_stock.empty()) legend(kDim, "The card's own curve (stock)", false); + if (!now.empty()) legend(kGood, now, true); + end_card(); +} + +namespace { + +void run_screen(UiState& s, const OptimizeWorker::Snapshot& run, const UiActions& act) { + begin_card("run"); + text_bold(run.is_retest ? "Testing the saved tune" + : s.preset == Preset::Undervolt ? "Finding the undervolt" + : (std::string("Optimizing: ") + title_of(s.preset)).c_str(), + 1.4f); + if (run.running) { + const std::string abort = with_icon(kIconStop, "Abort"); + align_right(button_width(abort) + em()); + if (ImGui::Button(abort.c_str(), ImVec2(button_width(abort) + em(), 0))) act.abort(); + } + dim(run.is_retest ? "Five minutes of full load with the tune applied. Aborting stops the test; the card goes back to " + "stock, or to the saved tune when that was on it before." + : "Aborting stops the running probe; the card goes back to stock, or to the saved tune when that was " + "on it before the run."); + ImGui::Spacing(); + // The run looked at what else uses the card before it began, found + // something, and waits for what to do about it. + if (run.running && !run.busy_question.empty()) { + text_bold((ic(kIconInfo) + " Other programs are using the graphics card").c_str(), 1.1f); + wrapped(kText, busy_text(run.busy_question) + "."); + wrapped(kDim, "A run measures the card under full load. What these programs take from it makes every number lower and less " + "steady: the result, and its comparison with stock. Close them and check again, or go on as it is. " + "Nothing is closed for you."); + ImGui::Spacing(); + if (primary_button("Check again")) act.answer_busy(BusyAnswer::CheckAgain); + ImGui::SameLine(); + if (ImGui::Button("Go on anyway")) act.answer_busy(BusyAnswer::GoOn); + ImGui::SameLine(); + if (ImGui::Button("Cancel")) act.answer_busy(BusyAnswer::Stop); + ImGui::Spacing(); + } + // 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); + 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(left) / 60, static_cast(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(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); + ImGui::Spacing(); + const float w = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) / 2; + char overlay[64]; + std::snprintf(overlay, sizeof(overlay), "temperature %s", reading(s.telemetry.temp_c, " C").c_str()); + plot("##temp", s.temp_history, 30, 90, overlay, w); + ImGui::SameLine(); + std::snprintf(overlay, sizeof(overlay), "power %s W", reading(s.telemetry.power_w, "").c_str()); + plot("##power", s.power_history, 0, static_cast(std::max(s.telemetry.power_limit_w, 1)), overlay, w); + end_card(); + + curve_card(s, "What the run writes to the card, read back every second. Higher is faster at that voltage.", true); + + // What is left of the page, and never less than a few lines: a card that + // sizes itself has no height to hand on to the list inside it. + begin_card("probes", std::max(em() * 14, ImGui::GetContentRegionAvail().y)); + heading("Probes"); + ImGui::PushStyleColor(ImGuiCol_ChildBg, kInner); + ImGui::BeginChild("lines", ImVec2(0, ImGui::GetContentRegionAvail().y), ImGuiChildFlags_Borders); + ImGui::PopStyleColor(); + for (const std::string& line : run.log) ImGui::TextUnformatted(line.c_str()); + if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) ImGui::SetScrollHereY(1.0f); + ImGui::EndChild(); + end_card(); + if (!run.running && run.ended()) s.screen = Screen::Results; +} + +} + +void optimize_page(UiState& s, const OptimizeWorker::Snapshot& run, const UiActions& act) { + if (run.running) s.screen = Screen::Run; + switch (s.screen) { + case Screen::Choose: choose(s, act); break; + case Screen::Run: run_screen(s, run, act); break; + case Screen::Results: results(s, run, act); break; + } +} + +} diff --git a/src/app/gui/ui_results.cpp b/src/app/gui/ui_results.cpp new file mode 100644 index 0000000..7776d38 --- /dev/null +++ b/src/app/gui/ui_results.cpp @@ -0,0 +1,213 @@ +#include "app/gui/ui_widgets.hpp" +#include + +namespace gao::gui { + +namespace { + +// A before/after table; `rows` fills it through `row(name, before, after)`. +// The two runs it compares differ in length, and it says so: a card is not +// heated through after half a minute, so the temperature and the fan speed +// of the short run read lower than they would on a warm card. +template +void comparison(const StabilityResult& before, const StabilityResult& after, Rows rows) { + if (!ImGui::BeginTable("cmp", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerH | ImGuiTableFlags_BordersOuter | + ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_PadOuterX)) + return; + ImGui::TableSetupColumn(""); + ImGui::TableSetupColumn(("Before (stock, " + duration_text(before.seconds) + ")").c_str()); + ImGui::TableSetupColumn(("After (" + duration_text(after.seconds) + ")").c_str()); + ImGui::TableHeadersRow(); + rows([](const char* name, const std::string& a, const std::string& b) { + ImGui::TableNextRow(ImGuiTableRowFlags_None, em() * 1.6f); + ImGui::TableNextColumn(); dim(name); + ImGui::TableNextColumn(); ImGui::TextUnformatted(a.c_str()); + ImGui::TableNextColumn(); ImGui::TextUnformatted(b.c_str()); + }); + ImGui::EndTable(); + if (before.verdict == Verdict::TooHot) + wrapped(kDim, "The stock run was stopped at " + reading(before.peak_temp_c, " \xC2\xB0""C") + " after " + duration_text(before.seconds) + + ": with the NVIDIA driver's own fan control (fans at " + reading(before.end_fan_pct, " %") + + ") the card gets that hot under full load. From there the run kept the fans on a curve."); + else if (after.seconds > 3 * before.seconds) + wrapped(kDim, "The stock run lasted " + duration_text(before.seconds) + + ", too short to heat the card through: its temperature and fan speed read lower than they would after " + + duration_text(after.seconds) + ". Score, clock and power compare directly."); +} + +// What a finished run found. True when a result is applied. +bool optimize_result(const app::OptimizeOutcome& o, bool put_back) { + if (!o.ran) { + heading("Not started"); + wrapped(kBad, o.error); + return false; + } + if (!o.result.ok) { + heading("Not applied"); + wrapped(kBad, o.result.reason); + if (!put_back) + wrapped(kText, o.result.stock_restored ? "The card is back at stock." : "Reset to stock FAILED -- run `gao --reset`."); + return false; + } + const OptimizeResult& r = o.result; + heading("Done"); + char line[160]; + std::snprintf(line, sizeof(line), "Applied: power %d %%, core +%d MHz, memory +%d MHz", r.power_pct, r.core_mhz, r.mem_mhz); + wrapped(kGood, line); + std::snprintf(line, sizeof(line), "Confirmed edges: core +%d MHz, memory +%d MHz. The difference is the safety margin.", + r.core_confirmed, r.mem_confirmed); + wrapped(kDim, line); + ImGui::Spacing(); + const StabilityResult& before = o.stock; // five minutes, like the soak + comparison(before, r.soak, [&](auto row) { + row("Score", score_text(before.score), against(before.score, r.soak.score, "it/s")); + row("Core clock", reading(before.avg_core_mhz, " MHz"), reading(r.soak.avg_core_mhz, " MHz")); + row("Memory clock", reading(before.avg_mem_mhz, " MHz"), reading(r.soak.avg_mem_mhz, " MHz")); + row("Peak temperature", reading(before.peak_temp_c, " \xC2\xB0""C"), reading(r.soak.peak_temp_c, " \xC2\xB0""C")); + row("Fan speed at the end", reading(before.end_fan_pct, " %"), reading(r.soak.end_fan_pct, " %")); + row("Power", reading(before.avg_power_w, " W"), reading(r.soak.avg_power_w, " W")); + }); + return true; +} + +bool undervolt_result(const app::UndervoltOutcome& o, bool put_back) { + if (!o.ran) { + heading("Not started"); + wrapped(kBad, o.error); + return false; + } + if (!o.result.ok) { + heading("Not applied"); + wrapped(kBad, o.result.reason); + if (!put_back) + wrapped(kText, o.result.stock_restored ? "The card is back at stock." : "Reset to stock FAILED -- run `gao --reset`."); + return false; + } + const UndervoltResult& r = o.result; + heading("Done"); + char line[200]; + std::snprintf(line, sizeof(line), "Applied: %d MHz at %d mV instead of %d mV", r.freq_khz / 1000, r.applied_uv / 1000, + r.stock_uv / 1000); + wrapped(kGood, line); + std::snprintf(line, sizeof(line), "Lowest stable voltage: %d mV, confirmed %d mV. The difference is the safety margin.", + r.edge_uv / 1000, r.confirmed_uv / 1000); + wrapped(kDim, line); + ImGui::Spacing(); + comparison(r.baseline, r.after, [&](auto row) { + row("Score", score_text(r.baseline.score), against(r.baseline.score, r.after.score, "it/s")); + row("Core clock", reading(r.baseline.avg_core_mhz, " MHz"), reading(r.after.avg_core_mhz, " MHz")); + row("Voltage for that clock", std::to_string(r.stock_uv / 1000) + " mV", std::to_string(r.applied_uv / 1000) + " mV"); + row("Peak temperature", reading(r.baseline.peak_temp_c, " \xC2\xB0""C"), reading(r.after.peak_temp_c, " \xC2\xB0""C")); + row("Fan speed at the end", reading(r.baseline.end_fan_pct, " %"), reading(r.after.end_fan_pct, " %")); + row("Power", reading(r.baseline.avg_power_w, " W"), + r.baseline.avg_power_w > 0 && r.after.avg_power_w >= 0 ? against(r.baseline.avg_power_w, r.after.avg_power_w, "W") + : reading(r.after.avg_power_w, " W")); + }); + return true; +} + +// The all-in-one run: what the card runs now, next to what it ran at stock. +bool all_in_one_result(const app::AllInOneOutcome& o, bool put_back) { + if (!o.ok) { + heading(o.ran ? "Not applied" : "Not started"); + wrapped(kBad, o.error); + if (o.ran && !put_back) wrapped(kText, "The card is at stock."); + return false; + } + const Profile& p = o.profile; + heading("Done"); + wrapped(kGood, "Applied: core " + offset(p.core_mhz) + ", memory " + offset(p.mem_mhz) + ", power limit " + std::to_string(p.power_pct) + + " %" + (p.undervolt ? ", " + undervolt_text(*p.undervolt) : std::string())); + if (o.fan.ok) + wrapped(kGood, "Fans: " + std::to_string(o.fan.hold_pct) + " % keeps the card at " + std::to_string(o.fan.hold_temp_c) + + " \xC2\xB0""C under full load; the fan curve goes through that point."); + if (!o.undervolt_note.empty()) wrapped(kWarn, "No undervolt on top of the overclock: " + o.undervolt_note + "."); + if (!o.fan_note.empty()) wrapped(kWarn, "Fans: " + o.fan_note + "."); + if (!o.disturbed.empty()) wrapped(kWarn, "The numbers below are not clean: " + o.disturbed + "."); + ImGui::Spacing(); + comparison(o.stock, o.now, [&](auto row) { + row("Score", score_text(o.stock.score), against(o.stock.score, o.now.score, "it/s")); + row("Core clock", reading(o.stock.avg_core_mhz, " MHz"), reading(o.now.avg_core_mhz, " MHz")); + row("Memory clock", reading(o.stock.avg_mem_mhz, " MHz"), reading(o.now.avg_mem_mhz, " MHz")); + row("Peak temperature", reading(o.stock.peak_temp_c, " \xC2\xB0""C"), reading(o.now.peak_temp_c, " \xC2\xB0""C")); + row("Fan speed at the end", reading(o.stock.end_fan_pct, " %"), reading(o.now.end_fan_pct, " %")); + row("Power", reading(o.stock.avg_power_w, " W"), + o.stock.avg_power_w > 0 && o.now.avg_power_w >= 0 ? against(o.stock.avg_power_w, o.now.avg_power_w, "W") + : reading(o.now.avg_power_w, " W")); + }); + return true; +} + +// The saved tune after its five minutes on a new driver. True: it is applied. +bool retest_result(const app::RetestOutcome& o) { + if (!o.ok) { + heading("Not applied"); + wrapped(kBad, o.error); + if (o.result.test.seconds > 0) + wrapped(kText, "The saved tune is kept as it is and stays off on this driver. Optimize again to find one that holds."); + return false; + } + heading("The tune holds"); + wrapped(kGood, "Five minutes of full load on driver " + o.driver + " (it was tested on " + o.driver_was + + "): stable. It is applied again: core " + offset(o.profile.core_mhz) + ", memory " + offset(o.profile.mem_mhz) + + ", power limit " + std::to_string(o.profile.power_pct) + " %" + + (o.profile.undervolt ? ", " + undervolt_text(*o.profile.undervolt) : std::string())); + const StabilityResult& t = o.result.test; + wrapped(kDim, "Score " + score_text(t.score) + ", core " + reading(t.avg_core_mhz, " MHz") + ", memory " + + reading(t.avg_mem_mhz, " MHz") + ", peak " + reading(t.peak_temp_c, " \xC2\xB0""C") + ", fans " + + reading(t.end_fan_pct, " %") + " at the end, " + reading(t.avg_power_w, " W") + "."); + return true; +} + +// After a driver reset the run recovers, but Windows and the other programs +// may not: on an RTX 5070 the desktop stayed black but for this window +// (hardware check 99). "Nothing left to do" would not be true then. +std::string reset_note(int resets) { + return "The graphics driver was reset during this run" + + (resets > 1 ? " (" + std::to_string(resets) + " times)" : std::string()) + + ". The run recovered, but Windows and other programs may not have: if windows stay black or do not respond, " + "sign out and back in, or restart the computer."; +} + +} + +void results(UiState& s, const OptimizeWorker::Snapshot& run, const UiActions& act) { + if (!run.ended()) { + s.screen = Screen::Choose; + return; + } + begin_card("results"); + const bool applied = run.retest ? retest_result(*run.retest) + : run.all_in_one ? all_in_one_result(*run.all_in_one, run.put_back) + : run.undervolt ? undervolt_result(*run.undervolt, run.put_back) + : optimize_result(*run.outcome, run.put_back); + // Stopped, and the saved tune that was on the card before the run is on it again (worker.cpp). + if (!applied && run.put_back) wrapped(kText, "Your saved tune is on the card again, as it was before the run."); + const int resets = run.all_in_one ? app::driver_resets(*run.all_in_one) + : run.undervolt ? app::driver_resets(*run.undervolt) + : run.outcome ? app::driver_resets(*run.outcome) + : 0; + if (!applied && resets > 0) wrapped(kWarn, reset_note(resets)); + if (applied) { + const bool saved = run.retest ? true : run.all_in_one ? run.all_in_one->ok : run.undervolt ? run.undervolt->saved : run.outcome->saved; + ImGui::Spacing(); + if (saved && s.boot_on) + wrapped(kText, resets > 0 ? "Saved, and applied at every logon from now on." + : "Saved, and applied at every logon from now on. There is nothing left to do."); + else if (saved) wrapped(kText, "Saved. It stays applied until reboot; turn on apply at logon to keep it."); + else wrapped(kWarn, "Not saved: " + (run.undervolt ? run.undervolt->save_note : run.outcome ? run.outcome->save_note : std::string())); + if (run.outcome && !run.outcome->busy_note.empty()) + wrapped(kWarn, run.outcome->busy_note + ". The numbers of this run and the ones at stock were not measured on the same card, " + "so the comparison can be off by several percent."); + if (resets > 0) wrapped(kWarn, reset_note(resets)); + if (saved && !s.boot_on && primary_button("Apply at every logon")) act.set_boot(true); + if (saved && !s.boot_on) ImGui::SameLine(); + if (ImGui::Button(with_icon(kIconUndo, "Revert to stock").c_str())) act.revert_to_stock(); + ImGui::SameLine(); + } + if (ImGui::Button(with_icon(kIconBack, "Back").c_str())) s.screen = Screen::Choose; + end_card(); + if (applied) curve_card(s, "As the run left it. Higher is faster at that voltage.", false); +} + +} diff --git a/src/app/gui/ui_widgets.cpp b/src/app/gui/ui_widgets.cpp new file mode 100644 index 0000000..defab96 --- /dev/null +++ b/src/app/gui/ui_widgets.cpp @@ -0,0 +1,291 @@ +#include "app/gui/ui_widgets.hpp" +#include "core/objectives.hpp" +#include +#include +#include + +namespace gao::gui { + +namespace { + +ImFont* g_regular = nullptr; + +constexpr const char* kFanPresetTitles[] = {"Silent", "Normal", "Cool", "Aggressive"}; + +} + +ImFont* g_bold = nullptr; + +// UTF-8 for a code point in the Basic Multilingual Plane's private use area. +std::string ic(unsigned cp) { + const char s[4] = {static_cast(0xE0 | (cp >> 12)), static_cast(0x80 | ((cp >> 6) & 0x3F)), + static_cast(0x80 | (cp & 0x3F)), 0}; + return s; +} +std::string with_icon(unsigned cp, const char* text) { return ic(cp) + " " + text; } + +float em() { return ImGui::GetFontSize(); } +float base() { return ImGui::GetStyle().FontSizeBase; } + +// The fan icon is drawn, not a glyph: the icon fonts of Windows have no fan +// (kIconFan's code point is a ring of dots). A casing ring, a hub and three +// swept blades inside a circle of `radius` around `c`. +void draw_fan(ImDrawList* dl, const ImVec2& c, float radius, ImU32 colour) { + constexpr float kTurn = 6.2831853f; + constexpr int kOutline = 20; // points around one blade + dl->AddCircle(c, radius * 0.92f, colour, 0, std::max(1.0f, radius * 0.12f)); + for (int k = 0; k < 3; ++k) { + // A blade: an ellipse half-way out on its spoke, leaning 52 degrees into the turn. + const float spoke = kTurn * static_cast(k) / 3 - kTurn / 4, lean = spoke + 0.9076f; + const ImVec2 mid(c.x + radius * 0.50f * std::cos(spoke), c.y + radius * 0.50f * std::sin(spoke)); + ImVec2 outline[kOutline]; + for (int i = 0; i < kOutline; ++i) { + const float t = kTurn * static_cast(i) / kOutline; + const float x = radius * 0.34f * std::cos(t), y = radius * 0.21f * std::sin(t); + outline[i] = ImVec2(mid.x + x * std::cos(lean) - y * std::sin(lean), mid.y + x * std::sin(lean) + y * std::cos(lean)); + } + dl->AddConvexPolyFilled(outline, kOutline, colour); + } + dl->AddCircleFilled(c, radius * 0.20f, colour); +} + +const char* title_of(Preset preset) { + if (preset == kUndervolt.preset) return kUndervolt.title; + if (preset == kAllInOne.preset) return kAllInOne.title; + for (const PresetInfo& p : kPresets) + if (p.preset == preset) return p.title; + return preset_name(preset); +} + +std::string offset(int mhz) { return (mhz >= 0 ? "+" : "") + std::to_string(mhz) + " MHz"; } +std::string duration_text(double seconds) { + char buf[32]; + if (seconds < 90) std::snprintf(buf, sizeof(buf), "%.0f s", seconds); + else std::snprintf(buf, sizeof(buf), "%.0f min", seconds / 60); + return buf; +} +std::string undervolt_text(const UndervoltTune& u) { + return std::to_string(u.freq_khz / 1000) + " MHz at " + std::to_string(u.volt_uv / 1000) + " mV"; +} + +std::string score_text(double score) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.0f it/s", score); + return buf; +} +// "5582 it/s (-1.0 %)", or the same for watts. +std::string against(double before, double after, const char* unit) { + char buf[64]; + std::snprintf(buf, sizeof(buf), "%.0f %s (%+.1f %%)", after, unit, before > 0 ? (after / before - 1) * 100 : 0.0); + return buf; +} + +// ------------------------------------------------------------------ widgets + +void text_bold(const char* text, float scale) { + ImGui::PushFont(g_bold, base() * scale); + ImGui::TextUnformatted(text); + ImGui::PopFont(); +} +void heading(const char* text) { text_bold(text, 1.2f); } +void dim(const char* text) { + ImGui::PushStyleColor(ImGuiCol_Text, kDim); + ImGui::TextUnformatted(text); + ImGui::PopStyleColor(); +} +void wrapped(const ImVec4& color, const std::string& text) { + ImGui::PushStyleColor(ImGuiCol_Text, color); + ImGui::PushTextWrapPos(0); + ImGui::TextUnformatted(text.c_str()); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); +} + +// A rounded panel. height 0: as tall as its contents. Always pair with end_card(). +// It never scrolls itself: the wheel goes to the page behind it. (A panel as +// tall as its contents can still be a pixel short of them, and then it +// kept the wheel: the page did not scroll with the mouse on such a card.) +void begin_card(const char* id, float height, const ImVec4& bg, float width) { + ImGui::PushStyleColor(ImGuiCol_ChildBg, bg); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(em() * 0.9f, em() * 0.75f)); + ImGui::BeginChild(id, ImVec2(width, height), + ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding | + (height == 0 ? ImGuiChildFlags_AutoResizeY : ImGuiChildFlags_None), + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); +} +void end_card() { ImGui::EndChild(); } + +// Puts the next item at the right edge of the current line, `width` wide. +void align_right(float width) { + ImGui::SameLine(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - width); +} + +float button_width(const std::string& label) { + return ImGui::CalcTextSize(label.c_str()).x + ImGui::GetStyle().FramePadding.x * 2; +} + +bool primary_button(const std::string& label, ImVec2 size) { + ImGui::PushStyleColor(ImGuiCol_Button, kAccent); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, kAccentHover); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, kAccentBright); + ImGui::PushStyleColor(ImGuiCol_Border, kAccent); + const bool pressed = ImGui::Button(label.c_str(), size); + ImGui::PopStyleColor(4); + return pressed; +} + +// An on/off switch; returns true when clicked. +bool toggle(const char* id, bool on) { + const float h = em() * 1.25f, w = h * 1.9f; + const ImVec2 p = ImGui::GetCursorScreenPos(); + const bool clicked = ImGui::InvisibleButton(id, ImVec2(w, h)); + ImDrawList* dl = ImGui::GetWindowDrawList(); + const ImVec4 track = on ? kAccent : (ImGui::IsItemHovered() ? kInnerHover : kInner); + dl->AddRectFilled(p, ImVec2(p.x + w, p.y + h), ImGui::GetColorU32(track), h / 2); + dl->AddRect(p, ImVec2(p.x + w, p.y + h), ImGui::GetColorU32(on ? kAccent : kBorder), h / 2); + const float r = h / 2 - h * 0.15f; + dl->AddCircleFilled(ImVec2(on ? p.x + w - h / 2 : p.x + h / 2, p.y + h / 2), r, ImGui::GetColorU32(kText)); + return clicked; +} + +// A telemetry tile: icon, label, value. +void tile(const char* id, Icon icon, const char* label, const std::string& value, float w, float h) { + ImGui::PushStyleColor(ImGuiCol_ChildBg, kInner); + ImGui::BeginChild(id, ImVec2(w, h), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar); + ImGui::PopStyleColor(); + const float content_h = em() * 2.45f; + ImGui::SetCursorPos(ImVec2(em() * 0.9f, (h - content_h) / 2 + em() * 0.3f)); + ImGui::PushFont(nullptr, base() * 1.8f); + ImGui::PushStyleColor(ImGuiCol_Text, kAccentBright); + if (icon == kIconFan) { // drawn, in the box a glyph of this size would take + const ImVec2 at = ImGui::GetCursorScreenPos(); + const float size = ImGui::GetFontSize(); + ImGui::Dummy(ImVec2(size, size)); + draw_fan(ImGui::GetWindowDrawList(), ImVec2(at.x + size / 2, at.y + size / 2), size * 0.5f, ImGui::GetColorU32(kAccentBright)); + } else { + ImGui::TextUnformatted(ic(icon).c_str()); + } + ImGui::PopStyleColor(); + ImGui::PopFont(); + ImGui::SameLine(0, em() * 0.8f); + ImGui::SetCursorPosY((h - content_h) / 2); + // Label and value as one block, as tall as content_h says: with the + // style's own line spacing between them the value hung at the bottom. + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, em() * 0.15f)); + ImGui::BeginGroup(); + dim(label); + // A value that is wider than the tile is drawn smaller, down to the + // label's own size, so that it is never cut off. + float scale = 1.3f; + ImGui::PushFont(g_bold, base() * scale); + const float need = ImGui::CalcTextSize(value.c_str()).x; + ImGui::PopFont(); + const float room = ImGui::GetContentRegionAvail().x - em() * 0.3f; + if (need > room && need > 0) scale = std::max(1.0f, scale * room / need); + text_bold(value.c_str(), scale); + ImGui::EndGroup(); + ImGui::PopStyleVar(); + ImGui::EndChild(); +} + +void telemetry_tiles(const UiState& s) { + const Telemetry& t = s.telemetry; + const float gap = ImGui::GetStyle().ItemSpacing.x; + const float w = (ImGui::GetContentRegionAvail().x - gap * 4) / 5, h = em() * 4.2f; + tile("core", kIconChip, "Core clock", reading(t.core_mhz, " MHz"), w, h); + ImGui::SameLine(); + tile("mem", kIconMemory, "Memory clock", reading(t.mem_mhz, " MHz"), w, h); + ImGui::SameLine(); + tile("temp", kIconTemp, "Temperature", reading(t.temp_c, " \xC2\xB0""C"), w, h); + ImGui::SameLine(); + tile("power", kIconPower, "Power", reading(t.power_w, "") + " / " + reading(t.power_limit_w, " W"), w, h); + ImGui::SameLine(); + // Who has the fans: the app's curve, the NVIDIA driver, another program, + // or, during a run, the run itself (its curve, or its fan measurement). + const char* mode = fan_owner_label( + fan_owner(s.fan_state.mode, s.fan_control && s.fan_available, s.run_active, s.run_tunes_fans, s.fan_manual)); + tile("fan", kIconFan, "Fan speed", reading(t.fan_pct, " %") + mode, w, h); +} + +// One button per fan preset; `current` is highlighted. Returns the clicked one. +std::optional fan_preset_buttons(const char* id, const std::optional& current) { + std::optional clicked; + ImGui::PushID(id); + for (size_t i = 0; i < std::size(kFanPresets); ++i) { + if (i > 0) ImGui::SameLine(); + const bool on = current == kFanPresets[i]; + if (on) { + ImGui::PushStyleColor(ImGuiCol_Button, kSelectedBg); + ImGui::PushStyleColor(ImGuiCol_Border, kAccentBright); + } + if (ImGui::Button(kFanPresetTitles[i])) clicked = kFanPresets[i]; + if (on) ImGui::PopStyleColor(2); + } + ImGui::PopID(); + return clicked; +} + +void load_fonts(const std::string& fonts_dir) { + ImGuiIO& io = ImGui::GetIO(); + auto add_icons = [&] { + ImFontConfig cfg; + cfg.MergeMode = true; + cfg.GlyphOffset.y = 3.0f; // the icon fonts sit higher than Segoe UI's baseline + if (!io.Fonts->AddFontFromFileTTF((fonts_dir + "\\SegoeIcons.ttf").c_str(), 17.0f, &cfg)) + io.Fonts->AddFontFromFileTTF((fonts_dir + "\\segmdl2.ttf").c_str(), 17.0f, &cfg); // Windows 10 + }; + g_regular = fonts_dir.empty() ? nullptr : io.Fonts->AddFontFromFileTTF((fonts_dir + "\\segoeui.ttf").c_str(), 17.0f); + if (!g_regular) { + g_regular = g_bold = io.Fonts->AddFontDefault(); + return; + } + add_icons(); + g_bold = io.Fonts->AddFontFromFileTTF((fonts_dir + "\\seguisb.ttf").c_str(), 17.0f); + if (g_bold) add_icons(); + else g_bold = g_regular; +} + +void apply_style(float scale) { + ImGuiStyle& st = ImGui::GetStyle(); + st = ImGuiStyle(); + ImGui::StyleColorsDark(); + ImVec4* c = st.Colors; + c[ImGuiCol_WindowBg] = kBg; + c[ImGuiCol_ChildBg] = ImVec4(0, 0, 0, 0); + c[ImGuiCol_PopupBg] = kCard; + c[ImGuiCol_Border] = kBorder; + c[ImGuiCol_Text] = kText; + c[ImGuiCol_TextDisabled] = kDim; + c[ImGuiCol_FrameBg] = kInner; + c[ImGuiCol_FrameBgHovered] = kInnerHover; + c[ImGuiCol_FrameBgActive] = kInnerHover; + c[ImGuiCol_Button] = kInner; + c[ImGuiCol_ButtonHovered] = kInnerHover; + c[ImGuiCol_ButtonActive] = kSelectedBg; + c[ImGuiCol_CheckMark] = kAccentBright; + c[ImGuiCol_Header] = kInner; + c[ImGuiCol_HeaderHovered] = kInnerHover; + c[ImGuiCol_HeaderActive] = kSelectedBg; + c[ImGuiCol_TableHeaderBg] = kInner; + c[ImGuiCol_TableBorderStrong] = kBorder; + c[ImGuiCol_TableBorderLight] = kBorder; + c[ImGuiCol_TableRowBg] = kInner; + c[ImGuiCol_TableRowBgAlt] = ImVec4(0.100f, 0.114f, 0.137f, 1); + c[ImGuiCol_PlotLines] = kAccentBright; + c[ImGuiCol_ScrollbarBg] = ImVec4(0, 0, 0, 0); + st.WindowRounding = 0; + st.ChildRounding = 8; + st.FrameRounding = 6; + st.GrabRounding = 6; + st.FrameBorderSize = 1; + st.ChildBorderSize = 1; + st.FramePadding = ImVec2(12, 7); + st.ItemSpacing = ImVec2(12, 12); + st.CellPadding = ImVec2(10, 4); + st.ScaleAllSizes(scale); + st.FontScaleDpi = scale; +} + +} diff --git a/src/app/gui/ui_widgets.hpp b/src/app/gui/ui_widgets.hpp new file mode 100644 index 0000000..fb999dc --- /dev/null +++ b/src/app/gui/ui_widgets.hpp @@ -0,0 +1,103 @@ +#pragma once +#include "app/gui/ui.hpp" +#include "imgui.h" +#include +#include + +// Shared by the pages of the window (ui*.cpp): colours, icons, fonts, +// widgets and text helpers. Not part of ui.hpp's interface. +namespace gao::gui { + +extern ImFont* g_bold; + +inline const ImVec4 kBg{0.067f, 0.075f, 0.090f, 1}; +inline const ImVec4 kNav{0.050f, 0.057f, 0.069f, 1}; +inline const ImVec4 kCard{0.090f, 0.102f, 0.122f, 1}; +inline const ImVec4 kInner{0.110f, 0.125f, 0.149f, 1}; +inline const ImVec4 kInnerHover{0.140f, 0.158f, 0.188f, 1}; +inline const ImVec4 kBorder{0.170f, 0.192f, 0.227f, 1}; +inline const ImVec4 kAccent{0.180f, 0.435f, 0.835f, 1}; +inline const ImVec4 kAccentHover{0.231f, 0.502f, 0.906f, 1}; +inline const ImVec4 kAccentBright{0.290f, 0.580f, 1.000f, 1}; +inline const ImVec4 kSelectedBg{0.098f, 0.145f, 0.220f, 1}; +inline const ImVec4 kText{0.900f, 0.910f, 0.930f, 1}; +inline const ImVec4 kDim{0.600f, 0.640f, 0.690f, 1}; +inline const ImVec4 kGood{0.360f, 0.780f, 0.500f, 1}; +inline const ImVec4 kWarn{0.910f, 0.710f, 0.230f, 1}; +inline const ImVec4 kBad{0.930f, 0.400f, 0.400f, 1}; + +// Segoe Fluent Icons (Windows 11) / Segoe MDL2 Assets (Windows 10) code points. +enum Icon : unsigned { + kIconHome = 0xE80F, kIconPulse = 0xE9D9, kIconInfo = 0xE946, kIconChip = 0xEEA1, kIconMemory = 0xE964, + kIconTemp = 0xE9CA, kIconPower = 0xE945, kIconFan = 0xF16A, kIconRefresh = 0xE72C, kIconUndo = 0xE777, + kIconPlay = 0xF5B0, kIconStop = 0xE71A, kIconClock = 0xE823, kIconStar = 0xE734, kIconQuiet = 0xE992, + kIconLeaf = 0xEC0A, kIconGauge = 0xEC4A, kIconShield = 0xEA18, kIconList = 0xE8FD, kIconBack = 0xE72B, + kIconDownload = 0xE896, kIconStarFilled = 0xE735, +}; + +struct PresetInfo { + Preset preset; + Icon icon; + const char* title; + const char* summary; +}; +inline constexpr PresetInfo kPresets[] = { + {Preset::BestOfMyGpu, kIconStar, "Best of my GPU", + "Highest confirmed clocks with a safety margin and the most power the card allows."}, + {Preset::CoolAndEfficient, kIconLeaf, "Cool & efficient", + "Power only: the lowest power limit that costs under 2 % speed, clocks left stock."}, + {Preset::Quiet, kIconQuiet, "Quiet", "Milder clocks and the lowest power limit that costs under 2 % speed."}, + {Preset::MaxPerformance, kIconGauge, "Max performance", + "Highest confirmed clocks minus one step and the highest power limit."}, +}; +// The one that does it all, shown first and across the full width. +inline constexpr PresetInfo kAllInOne = {Preset::AllInOne, kIconStarFilled, "All in one", + "Overclock, then an undervolt on top of it, then the quietest fan curve that holds the " + "temperature: more speed than stock on less power, tested as a whole. One click, about 40 " + "minutes."}; +// Not a clock search, and shown apart from the four above: the speed the +// card has at stock, on the lowest voltage that holds it. +inline constexpr PresetInfo kUndervolt = {Preset::Undervolt, kIconPower, "Undervolt", + "The same speed on less voltage: less power, less heat and quieter fans. Clocks and the " + "power limit stay stock. It replaces a saved overclock."}; + +inline constexpr FanPreset kFanPresets[] = {FanPreset::Silent, FanPreset::Normal, FanPreset::Cool, FanPreset::Aggressive}; + +std::string ic(unsigned cp); +std::string with_icon(unsigned cp, const char* text); +float em(); +float base(); +void draw_fan(ImDrawList* dl, const ImVec2& c, float radius, ImU32 colour); + +const char* title_of(Preset preset); +std::string offset(int mhz); +std::string duration_text(double seconds); +std::string undervolt_text(const UndervoltTune& u); +std::string score_text(double score); +std::string against(double before, double after, const char* unit); + +void text_bold(const char* text, float scale = 1.0f); +void heading(const char* text); +void dim(const char* text); +void wrapped(const ImVec4& color, const std::string& text); +void begin_card(const char* id, float height = 0, const ImVec4& bg = kCard, float width = 0); +void end_card(); +void align_right(float width); +float button_width(const std::string& label); +bool primary_button(const std::string& label, ImVec2 size = ImVec2(0, 0)); +bool toggle(const char* id, bool on); +void tile(const char* id, Icon icon, const char* label, const std::string& value, float w, float h); +void telemetry_tiles(const UiState& s); +std::optional fan_preset_buttons(const char* id, const std::optional& current); + +// The pages, drawn by draw_ui(). +void dashboard(UiState& s, const UiActions& act); +void optimize_page(UiState& s, const OptimizeWorker::Snapshot& run, const UiActions& act); +void fan_page(UiState& s, const UiActions& act); +void about(const UiState& s, const UiActions& act); + +// Parts of the Optimize page that live in their own file. +void curve_card(const UiState& s, const char* caption, bool live); +void results(UiState& s, const OptimizeWorker::Snapshot& run, const UiActions& act); + +} From 2ebc4ff974332425ce11a534b62f0074a65d9f45 Mon Sep 17 00:00:00 2001 From: Bejeweld Date: Sun, 18 Oct 2026 11:25:48 +0200 Subject: [PATCH 04/10] refactor(cli): move the commands out of main.cpp main.cpp keeps the entry point, the argument dispatch and the usage text. The commands that read or set the card directly move to cli_hardware.cpp, the stress tests and runs with their Ctrl+C handling to cli_runs.cpp; cli_commands.hpp declares them and the shared run state (g_abort, g_busy_ok), defined once in cli_runs.cpp. Code moved unchanged. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 3 +- src/app/cli_commands.hpp | 54 ++++ src/app/cli_hardware.cpp | 289 ++++++++++++++++++++ src/app/cli_runs.cpp | 277 ++++++++++++++++++++ src/app/main.cpp | 550 +-------------------------------------- 5 files changed, 625 insertions(+), 548 deletions(-) create mode 100644 src/app/cli_commands.hpp create mode 100644 src/app/cli_hardware.cpp create mode 100644 src/app/cli_runs.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 29a1458..236b346 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -104,7 +104,8 @@ add_library(app STATIC ) target_link_libraries(app PUBLIC core hw PRIVATE gao_warnings gao_hardening) -add_executable(gao src/app/main.cpp src/app/gao.manifest src/app/app.rc) +add_executable(gao src/app/main.cpp src/app/cli_commands.hpp src/app/cli_hardware.cpp src/app/cli_runs.cpp + src/app/gao.manifest src/app/app.rc) target_link_libraries(gao PRIVATE app gao_warnings gao_hardening) # d3dcompiler_47.dll, d3d11.dll and dxgi.dll are not KnownDLLs: delay-load # them so they resolve after SetDefaultDllDirectories(SYSTEM32) in main(), diff --git a/src/app/cli_commands.hpp b/src/app/cli_commands.hpp new file mode 100644 index 0000000..c8e08a6 --- /dev/null +++ b/src/app/cli_commands.hpp @@ -0,0 +1,54 @@ +#pragma once +// The gao.exe commands as main.cpp dispatches them (cli_hardware.cpp, +// cli_runs.cpp); CLI only, not part of the interface of common.hpp. +#include "app/common.hpp" +#include "hw/stress.hpp" +#include +#include +#include +#include + +namespace gao::cli { + +// cli_hardware.cpp +int probe(); +int set_offset(const char* label, int mhz, bool core); +int curve(); +int curve_flatten(int mv, int mhz, int tail_drop_mhz, int core_mhz); +int reset(); +int update(); +int apply(); +int boot(bool on); +int status(); +int fan_auto(); + +// cli_runs.cpp +int stress(int seconds, int max_temp_c, gao::StressSelftest selftest, int duty_pct); +int optimize(gao::Preset preset, const std::optional& fan_curve); +int undervolt(bool on_overclock); +int retest(); +int all_in_one(); +int bandwidth(int after_load_s); +int stress_recreate(); + +// Defined in cli_runs.cpp: the Ctrl+C handler sets g_abort; main() sets +// g_busy_ok for --busy-ok. +extern std::atomic g_abort; +extern bool g_busy_ok; + + +// A run Ctrl+C stopped, with nothing of it kept (exit 1), puts back the tune +// it found on the card: a tray app's watchdog would otherwise take the stock +// it left for a driver reset. The window's runs do the same (gui/worker.cpp). +template int putting_back(Run run) { + const auto before = gao::app::tune_before_run(); + const int code = run(); + if (g_abort && code == 1) { + gao::app::OptimizeHooks hooks; + hooks.log = gao::app::logged([](const std::string& msg) { std::printf(" %s\n", msg.c_str()); }); + gao::app::put_back_after_stop(before, hooks); + } + return code; +} + +} // namespace gao::cli diff --git a/src/app/cli_hardware.cpp b/src/app/cli_hardware.cpp new file mode 100644 index 0000000..cdd2df3 --- /dev/null +++ b/src/app/cli_hardware.cpp @@ -0,0 +1,289 @@ +// The gao.exe commands that read or set the card directly, one call each: +// probe, offsets, curve, reset, apply, boot, status, fans and update. +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include "app/cli_commands.hpp" +#include "app/common.hpp" +#include "core/version.hpp" +#include "core/vf_curve.hpp" +#include "hw/app_files.hpp" +#include "hw/boot_task.hpp" +#include "hw/gpu_control.hpp" +#include "hw/nvapi.hpp" +#include "hw/nvidia_app.hpp" +#include "hw/nvml.hpp" +#include +#include +#include + +namespace gao::cli { + +using gao::app::kGpu; + +int probe() { + gao::Nvml nvml; + if (!nvml.Init()) { + std::printf("NVML init failed: %s\n", nvml.Error().c_str()); + return 1; + } + const int count = nvml.DeviceCount(); + if (count < 0) { + std::printf("NVML device count failed: %s\n", nvml.Error().c_str()); + return 1; + } + std::printf("GPUs: %d\n", count); + for (int i = 0; i < count; ++i) { + const gao::Telemetry t = nvml.Read(static_cast(i)); + if (!t.ok) { std::printf(" [%d] read failed\n", i); continue; } + // Each field is -1 when the driver did not report it, even when t.ok. + std::printf(" [%d] core=%s MHz mem=%s MHz temp=%s C fan=%s power=%s/%d W\n", i, + gao::reading(t.core_mhz).c_str(), gao::reading(t.mem_mhz).c_str(), gao::reading(t.temp_c).c_str(), + gao::reading(t.fan_pct, "%").c_str(), gao::reading(t.power_w).c_str(), t.power_limit_w); + } + // The search's outer bound (core/search.hpp: search_bounds). + gao::Nvapi nvapi; + if (!nvapi.Init()) { + std::printf("offset ranges: unavailable (NVAPI init failed: %s)\n", nvapi.Error().c_str()); + return 0; + } + const auto ranges = nvapi.ReadOffsetRangesMhz(gao::app::kGpu); + if (!ranges) { + std::printf("offset ranges: unavailable (%s)\n", nvapi.Error().c_str()); + return 0; + } + std::printf("offset ranges as the driver reports them: core %+d to %+d MHz, mem %+d to %+d MHz\n", + ranges->core.min_mhz, ranges->core.max_mhz, ranges->mem.min_mhz, ranges->mem.max_mhz); + return 0; +} + +// Shared by --set-core and --set-mem. Prints "requested X, read back Y" and +// OK/MISMATCH. Always prints the read-back, even on success -- the number +// being visible is the point, not just the verdict. +int set_offset(const char* label, int mhz, bool core) { + gao::Nvapi nvapi; + if (!nvapi.Init()) { std::printf("NVAPI init failed: %s\n", nvapi.Error().c_str()); return 1; } + const bool ok = core ? nvapi.SetCoreOffsetMhz(kGpu, mhz) : nvapi.SetMemOffsetMhz(kGpu, mhz); + const auto readback = nvapi.ReadOffsetsMhz(kGpu); + std::printf("%s: requested %d MHz, read back ", label, mhz); + if (readback) std::printf("%d MHz\n", core ? readback->first : readback->second); + else std::printf("unavailable (%s)\n", nvapi.Error().c_str()); + // Print the diagnostic before the verdict: "clamped to + // 210 MHz" and "the call never reached an unelevated driver" both show + // MISMATCH here, and without this line they are indistinguishable. + if (!ok) std::printf("%s\n", nvapi.Error().c_str()); + std::printf("%s\n", ok ? "OK" : "MISMATCH"); + return ok ? 0 : 1; +} + +static void print_curve(const std::vector& curve) { + std::printf("voltage/frequency curve: %zu points, %d to %d mV\n", curve.size(), curve.front().volt_uv / 1000, + curve.back().volt_uv / 1000); + std::printf(" slot mV MHz raw offset\n"); + for (const gao::VfPoint& p : curve) + std::printf(" %4d %4d %6.1f %d\n", p.index, p.volt_uv / 1000, p.freq_khz / 1000.0, p.raw_offset); +} + +// Read-only: what the driver reports as the curve. Exit 1 when it cannot be read. +int curve() { + gao::Nvapi nvapi; + if (!nvapi.Init()) { std::printf("NVAPI init failed: %s\n", nvapi.Error().c_str()); return 1; } + const auto points = nvapi.ReadVfCurve(kGpu); + if (!points) { + std::printf("curve: unavailable (%s)%s\n", nvapi.Error().c_str(), + gao::app::is_elevated() ? "" : "; reading it may need an administrator shell"); + return 1; + } + print_curve(*points); + return 0; +} + +// Writes a flat top by hand: the point at or just above runs and +// no point above it runs more; those are stored lower (0: a +// top that is exactly flat). With the flat top sits on that core +// offset, which is set first and stays on the points below the anchor. For +// the hardware checks; `gao --reset` undoes it. +int curve_flatten(int mv, int mhz, int tail_drop_mhz, int core_mhz) { + const gao::app::TuningLock lock; + if (!lock.owned()) { std::printf("another optimize is already running (in the app or on the command line)\n"); return 1; } + // A running tray app would take the missing core offset for a driver + // reset and write its saved tune over the flat top: it is told to leave + // the card alone, as after `gao --reset`. + gao::app::tell_tray(gao::app::TrayNotice::StockByChoice); + gao::Nvml nvml; + gao::Nvapi nvapi; + if (!nvml.Init()) { std::printf("NVML init failed: %s\n", nvml.Error().c_str()); return 1; } + if (!nvapi.Init()) { std::printf("NVAPI init failed: %s\n", nvapi.Error().c_str()); return 1; } + const gao::GpuControl gpu = gao::make_gpu_control(nvml, nvapi, kGpu); + if (core_mhz != 0 && !gpu.set_core_offset(core_mhz)) { + std::printf("core offset %+d MHz: %s\n", core_mhz, nvapi.Error().c_str()); + return 1; + } + const auto before = gpu.read_vf_curve(); + if (!before) { std::printf("curve: unavailable (%s)\n", nvapi.Error().c_str()); return 1; } + int anchor = -1; + for (const gao::VfPoint& p : *before) // lowest voltage first + if (p.volt_uv >= mv * 1000) { anchor = p.index; break; } + if (anchor < 0) { std::printf("no curve point at or above %d mV\n", mv); return 1; } + const gao::VfApplyResult r = gao::apply_flat_top(gpu, anchor, mhz * 1000, tail_drop_mhz * 1000, core_mhz * 1000); + if (const auto after = gpu.read_vf_curve()) print_curve(*after); + if (!r.ok) { std::printf("not applied: %s\n", r.why.c_str()); return 1; } + std::printf("flat top applied: slot %d runs %d MHz, in %d write rounds -- OK\n", anchor, mhz, r.passes); + return 0; +} + +int reset() { + // Stock by choice: tell a running tray app first, or a watchdog tick + // between the writes and the notice would take it for a TDR and re-apply. + gao::app::tell_tray(gao::app::TrayNotice::StockByChoice); + gao::Nvapi nvapi; + if (!nvapi.Init()) { std::printf("NVAPI init failed: %s\n", nvapi.Error().c_str()); return 1; } + // A flat top on the voltage/frequency curve first: the offset reset below + // is not known to remove one. + gao::GpuControl curve; + curve.read_vf_curve = [&nvapi] { return nvapi.ReadVfCurve(kGpu); }; + curve.write_vf_offsets = [&nvapi](const std::vector& offsets) { return nvapi.WriteVfRawOffsets(kGpu, offsets); }; + std::string why; + bool ok = gao::remove_vf_shape(curve, &why); + if (!ok) std::printf("curve: %s\n", why.c_str()); + ok = nvapi.ResetOffsets(kGpu) && ok; + const auto readback = nvapi.ReadOffsetsMhz(kGpu); + std::printf("reset: requested core 0 MHz, mem 0 MHz\n"); + if (readback) std::printf("read back core %d MHz, mem %d MHz\n", readback->first, readback->second); + else std::printf("read back unavailable (%s)\n", nvapi.Error().c_str()); + if (!ok) std::printf("%s\n", nvapi.Error().c_str()); + // Power limit back to the driver default, where the card supports it. + gao::Nvml nvml; + if (!nvml.Init()) { + std::printf("power limit: not restored (NVML: %s)\n", nvml.Error().c_str()); + ok = false; + } else if (nvml.PowerLimitRangePct(kGpu)) { + const bool power_ok = nvml.SetPowerLimitPct(kGpu, 100); + std::printf("power limit: default %s\n", power_ok ? "restored" : nvml.Error().c_str()); + ok = ok && power_ok; + } + std::printf("%s\n", ok ? "OK" : "MISMATCH"); + return ok ? 0 : 1; +} + +// Exit 0: updated, or already the latest. 1: the check or the update failed. +int update() { + const auto check = gao::app::check_for_update(); + if (!check.error.empty()) { std::printf("could not check for updates: %s\n", check.error.c_str()); return 1; } + if (!check.release) { std::printf("up to date: this is %s and there is no newer release\n", gao::kVersion.data()); return 0; } + std::printf("version %s is available (this is %s); downloading\n", check.release->version.c_str(), gao::kVersion.data()); + std::string message; + const bool ok = gao::app::install_update(*check.release, &message); + std::printf("%s\n", message.c_str()); + if (ok) std::printf("A running tray app keeps its old version until it is restarted.\n"); + return ok ? 0 : 1; +} + +int apply() { + if (!gao::app::is_elevated()) { std::printf("--apply needs an elevated (administrator) shell\n"); return 1; } + std::string why; + if (!gao::app::prepare_state(&why)) { std::printf("%s\n", why.c_str()); return 1; } + gao::Nvml nvml; + if (!nvml.Init()) { std::printf("NVML init failed: %s\n", nvml.Error().c_str()); return 1; } + gao::Nvapi nvapi; + if (!nvapi.Init()) { std::printf("NVAPI init failed: %s\n", nvapi.Error().c_str()); return 1; } + gao::Config cfg = gao::app::load_config(); + cfg.boot_strikes = 0; // strikes only gate boot-apply + const std::string driver = nvml.DriverVersion(); + const auto d = gao::decide_boot(cfg, driver, nvml.GpuUuid(kGpu)); + if (d != gao::BootDecision::Apply) { + std::printf("not applied: %s\n", gao::app::decision_text(d, cfg, driver).c_str()); + return 1; + } + const gao::GpuControl gpu = gao::make_gpu_control(nvml, nvapi, kGpu); + if (!gao::apply_profile(gpu, *cfg.profile, &why)) { std::printf("not applied: %s\n", why.c_str()); return 1; } + gao::app::tell_tray(gao::app::TrayNotice::TuneApplied); // a running tray keeps it applied from now on + std::printf("applied %s\nOK\n", gao::app::profile_text(*cfg.profile).c_str()); + return 0; +} + +int boot(bool on) { + std::string message; + const bool ok = on ? gao::app::enable_boot(&message) : gao::app::disable_boot(&message); + std::printf("%s\n", message.c_str()); + return ok ? 0 : 1; +} + +int status() { + const auto text = gao::read_file(gao::config_path()); + const gao::Config cfg = text ? gao::from_json(*text) : gao::Config{}; + if (cfg.profile) std::printf("profile: %s\n", gao::app::profile_text(*cfg.profile).c_str()); + else if (text) std::printf("profile: none valid in %s\n", gao::config_path().string().c_str()); + else std::printf("profile: none (run `gao --optimize`)\n"); + gao::Nvml nvml; + const std::string driver = nvml.Init() ? nvml.DriverVersion() : std::string(); + if (cfg.profile) { + std::printf("driver: %s (%s)\n", driver.empty() ? "unknown" : driver.c_str(), + !driver.empty() && driver == cfg.profile->driver ? "matches" : "CHANGED -- run `gao --optimize` again"); + const std::string gpu_id = nvml.GpuUuid(kGpu); + std::printf("gpu: %s\n", !gpu_id.empty() && gpu_id == cfg.profile->gpu + ? "matches the profile" : "DIFFERENT card or unknown -- run `gao --optimize` again"); + } + // What the driver reports right now, whoever set it. + gao::Nvapi nvapi; + const auto applied = nvapi.Init() ? gao::make_gpu_control(nvml, nvapi, kGpu).read_applied() : std::nullopt; + if (applied) + std::printf("applied: power %d %%, core %+d MHz, mem %+d MHz\n", applied->power_pct, applied->core_mhz, applied->mem_mhz); + else + std::printf("applied: unknown (could not read the driver)\n"); + // The offsets above say nothing about a flat top: the curve does. + if (cfg.profile && cfg.profile->undervolt) { + const auto points = nvapi.ReadVfCurve(kGpu); + const char* state = "unknown (the curve could not be read)"; + if (points) { + switch (gao::curve_state(*points, cfg.profile->undervolt->volt_uv, cfg.profile->core_mhz * 1000)) { + case gao::CurveState::FlatTop: state = "applied"; break; + case gao::CurveState::Stock: state = "not applied (the curve is at stock)"; break; + case gao::CurveState::Other: state = "not applied (the curve carries other offsets)"; break; + } + } + std::printf("undervolt: %s\n", state); + } + if (const auto fan = nvml.ReadFan(kGpu)) + std::printf("fans: %s\n", fan->manual ? ("manual " + std::to_string(fan->target_pct) + " %").c_str() : "driver control"); + if (const auto target = nvml.TempTarget(kGpu)) { + const auto usual = nvapi.TempTargetDefaultC(kGpu); + const std::string note = !usual ? std::string() : *usual == *target ? ", its default" : ", NOT its default of " + std::to_string(*usual) + " C"; + std::printf("temp target: %d C (the card's own: from there it slows itself down%s)\n", *target, note.c_str()); + } else { + std::printf("temp target: not reported by this card\n"); + } + const auto curve = gao::active_fan_curve(cfg); + std::printf("fan curve: %s", cfg.fan_control && curve ? "on" : "off"); + if (curve) { + if (curve->stop_below_c) std::printf(", driver below %d C", *curve->stop_below_c); + for (const gao::FanPoint& p : curve->points) std::printf(", %d C %d %%", p.temp_c, p.pct); + } + std::printf("\n"); + if (cfg.fan_min_pct > 0) std::printf("fan minimum: %d %% (learned: the fans stall below it)\n", cfg.fan_min_pct); + std::printf("boot-apply: %s\n", gao::boot_task_exists() ? "on (logon task registered)" : "off"); + // No run starts while it is on (app/common.cpp). + const std::optional nvidia = gao::nvidia_auto_tuning(); + std::printf("nvidia app: Automatic Tuning %s\n", + !nvidia ? "unknown (its setting could not be read)" : *nvidia ? "ON -- switch it off before a run" : "off"); + std::error_code ec; + if (!std::filesystem::exists(gao::installed_exe_path(), ec)) std::printf("boot copy: not installed\n"); + else if (gao::app::logon_copy_is_this_build()) std::printf("boot copy: up to date\n"); + else std::printf("boot copy: OUTDATED -- run `gao --boot on` to install this build\n"); + std::printf("strikes: %d of %d\n", cfg.boot_strikes, gao::kMaxBootStrikes); + const auto log = gao::read_lines(gao::boot_log_path()); + if (log && !log->empty()) std::printf("last boot: %s\n", log->back().c_str()); + return 0; +} + +// Emergency exit: every fan back to the driver, whatever set it. +int fan_auto() { + gao::Nvml nvml; + if (!nvml.Init()) { std::printf("NVML init failed: %s\n", nvml.Error().c_str()); return 1; } + const bool ok = nvml.SetFanAuto(kGpu); + if (!ok) std::printf("%s\n", nvml.Error().c_str()); + std::printf("fans: %s\n", ok ? "driver control -- OK" : "MISMATCH"); + return ok ? 0 : 1; +} + +} // namespace gao::cli diff --git a/src/app/cli_runs.cpp b/src/app/cli_runs.cpp new file mode 100644 index 0000000..17c7325 --- /dev/null +++ b/src/app/cli_runs.cpp @@ -0,0 +1,277 @@ +// The gao.exe commands that load the card: the stress tests, the bandwidth +// measurement and the runs (optimize, undervolt, retest, all in one), with +// the Ctrl+C handling the runs share. +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include "app/cli_commands.hpp" +#include "app/common.hpp" +#include "core/stability.hpp" +#include "core/stress_math.hpp" +#include "hw/gpu_control.hpp" +#include "hw/nvml.hpp" +#include "hw/stress.hpp" +#include +#include +#include +#include +#include +#include + +namespace gao::cli { + +using gao::app::kGpu; + +// Runs the DX11 stress load for `seconds` of GPU time, printing one line per +// second, then the verdict. Changes no settings, so Ctrl+C is always safe. +// duty_pct < 100: a light load, the card working that share of `seconds` of +// wall time and idle in between (duty_pause_ms); the lines then come per +// second of GPU time. +// Assumes the first NVIDIA DXGI adapter is NVML device kGpu -- true on a +// single-GPU machine, which is all this CLI supports (see kGpu). +int stress(int seconds, int max_temp_c, gao::StressSelftest selftest, int duty_pct) { + gao::Nvml nvml; + if (!nvml.Init()) { std::printf("NVML init failed: %s\n", nvml.Error().c_str()); return 1; } + gao::Stress load; + if (!load.Init(selftest)) { std::printf("stress init failed: %s\n", load.Error().c_str()); return 1; } + std::printf("stress: %s, %d s, abort above %d C", load.AdapterName().c_str(), seconds, max_temp_c); + if (duty_pct < 100) std::printf(", working %d %% of the time", duty_pct); + std::printf("\n"); + const double gpu_seconds = duty_pct < 100 ? std::max(1.0, seconds * duty_pct / 100.0) : seconds; + + double t = 0, next_print = 1.0; + long long window_its = 0; + double window_s = 0; + auto batch = [&] { + const gao::StressBatch b = load.Batch(); + if (const int pause = gao::duty_pause_ms(b.elapsed_ms, duty_pct); pause > 0) + std::this_thread::sleep_for(std::chrono::milliseconds(pause)); + t += b.elapsed_ms / 1000.0; + window_s += b.elapsed_ms / 1000.0; + window_its += b.iterations; + return b; + }; + auto read = [&] { + const gao::Telemetry tel = nvml.Read(kGpu); + if (t >= next_print) { + std::printf(" t=%.0fs score=%.0f it/s core=%s MHz mem=%s MHz temp=%s C power=%s/%d W\n", t, + window_its / window_s, gao::reading(tel.core_mhz).c_str(), gao::reading(tel.mem_mhz).c_str(), + gao::reading(tel.temp_c).c_str(), gao::reading(tel.power_w).c_str(), tel.power_limit_w); + window_its = 0; + window_s = 0; + next_print = t + 1.0; + } + return tel; + }; + const gao::StabilityResult r = gao::run_stability(batch, read, gpu_seconds, max_temp_c); + std::printf("VERDICT: %s score=%.0f it/s %.1f s peak=%s C avg power=%s W avg core=%s MHz avg mem=%s MHz\n", + gao::verdict_name(r.verdict), r.score, r.seconds, gao::reading(r.peak_temp_c).c_str(), + gao::reading(r.avg_power_w).c_str(), gao::reading(r.avg_core_mhz).c_str(), + gao::reading(r.avg_mem_mhz).c_str()); + switch (r.verdict) { + case gao::Verdict::Stable: return 0; + case gao::Verdict::WrongResult: + case gao::Verdict::DeviceLost: + case gao::Verdict::Stalled: return 2; + case gao::Verdict::TooHot: + case gao::Verdict::NoTelemetry: return 3; + case gao::Verdict::Aborted: return 1; + } + return 1; +} + +std::atomic g_abort{false}; +static const gao::GpuControl* g_gpu = nullptr; // set while --optimize runs + +// Ctrl+C / Ctrl+Break do not kill the process during --optimize: they ask +// the search to stop, and the search restores stock before returning. +// Closing the window, logoff and shutdown do kill it (Windows allows ~5 s), +// so those reset to stock right here instead of leaving a candidate applied. +static BOOL WINAPI OnConsoleCtrl(DWORD type) { + if (type == CTRL_C_EVENT || type == CTRL_BREAK_EVENT) { + g_abort = true; + std::printf("\nabort requested -- stopping the probe; the card goes back to stock, or to the tune it had before the run\n"); + return TRUE; + } + if (type == CTRL_CLOSE_EVENT || type == CTRL_LOGOFF_EVENT || type == CTRL_SHUTDOWN_EVENT) { + g_abort = true; + if (g_gpu && g_gpu->reset_to_stock) g_gpu->reset_to_stock(); + if (g_gpu && g_gpu->set_fan_auto) g_gpu->set_fan_auto(); + return TRUE; + } + return FALSE; +} + +// --busy-ok, as the last argument of a run: other programs may stay on the card. +bool g_busy_ok = false; + +// Other programs use the card: the run says which, and does not start unless +// it was told that this is fine. +static gao::BusyAnswer on_busy(const std::vector& programs) { + if (g_busy_ok) return gao::BusyAnswer::GoOn; + std::printf("Other programs are using the graphics card: %s.\n" + "A run measures the card under full load, and what they take from it makes every number lower and less\n" + "steady: the result, and its comparison with stock. Close them and start again, or add --busy-ok as the\n" + "last argument to go on as it is.\n", gao::busy_text(programs).c_str()); + return gao::BusyAnswer::Stop; +} + +int optimize(gao::Preset preset, const std::optional& fan_curve) { + gao::app::OptimizeHooks hooks; + hooks.aborted = [] { return g_abort.load(); }; + hooks.log = gao::app::logged([](const std::string& msg) { std::printf(" %s\n", msg.c_str()); }); + hooks.busy = on_busy; + hooks.active_gpu = [](const gao::GpuControl* gpu) { + g_gpu = gpu; + SetConsoleCtrlHandler(OnConsoleCtrl, gpu ? TRUE : FALSE); + }; + const auto out = gao::app::run_optimize(preset, hooks, fan_curve); + // Through the log: on screen and in run.log alike. + for (const std::string& line : gao::app::result_lines(out)) hooks.log(line); + if (!out.ran || !out.result.ok) return 1; + if (out.saved) { + std::printf("Saved: `gao --apply` re-applies it, `gao --boot on` applies it at every logon.\n"); + if (const std::string note = gao::app::update_logon_copy(); !note.empty()) std::printf("%s\n", note.c_str()); + } + std::printf("Applied until reboot. `gao --reset` returns to stock.\n"); + return out.saved ? 0 : 2; // 2: tuned and applied, but --apply and boot-apply cannot use it +} + +// Finds the undervolt, leaves it applied and saves it as the profile; on_overclock: on top of the saved tune, into it. +int undervolt(bool on_overclock) { + gao::app::OptimizeHooks hooks; + hooks.aborted = [] { return g_abort.load(); }; + hooks.log = gao::app::logged([](const std::string& msg) { std::printf(" %s\n", msg.c_str()); }); + hooks.busy = on_busy; + hooks.active_gpu = [](const gao::GpuControl* gpu) { + g_gpu = gpu; + SetConsoleCtrlHandler(OnConsoleCtrl, gpu ? TRUE : FALSE); + }; + // A running tray app holds off for the whole run (the tuning lock), as + // for --optimize, and afterwards finds the profile this run saved. + const auto out = gao::app::run_undervolt(hooks, on_overclock); + for (const std::string& line : gao::app::result_lines(out)) hooks.log(line); + if (!out.ran || !out.result.ok) return 1; + if (out.saved) { + std::printf("Saved: `gao --apply` re-applies it, `gao --boot on` applies it at every logon.\n"); + if (const std::string note = gao::app::update_logon_copy(); !note.empty()) std::printf("%s\n", note.c_str()); + } + std::printf("Applied until reboot. `gao --reset` returns to stock.\n"); + return out.saved ? 0 : 2; // 2: found and applied, but --apply and boot-apply cannot use it +} + +// The saved tune, tested again on the driver that is installed now. +int retest() { + gao::app::OptimizeHooks hooks; + hooks.aborted = [] { return g_abort.load(); }; + hooks.log = gao::app::logged([](const std::string& msg) { std::printf(" %s\n", msg.c_str()); }); + hooks.busy = on_busy; + hooks.active_gpu = [](const gao::GpuControl* gpu) { + g_gpu = gpu; + SetConsoleCtrlHandler(OnConsoleCtrl, gpu ? TRUE : FALSE); + }; + const gao::app::RetestOutcome out = gao::app::run_retest(hooks); + for (const std::string& line : gao::app::result_lines(out)) hooks.log(line); + return out.ok ? 0 : 1; +} + +// The all-in-one run: stock, overclock, undervolt on top, fans, final test. +int all_in_one() { + gao::app::OptimizeHooks hooks; + hooks.aborted = [] { return g_abort.load(); }; + hooks.log = gao::app::logged([](const std::string& msg) { std::printf(" %s\n", msg.c_str()); }); + hooks.busy = on_busy; + hooks.active_gpu = [](const gao::GpuControl* gpu) { + g_gpu = gpu; + SetConsoleCtrlHandler(OnConsoleCtrl, gpu ? TRUE : FALSE); + }; + // The measuring steps have no clock candidate applied, but they run for + // minutes: Ctrl+C must stop them too, and end at stock. + SetConsoleCtrlHandler(OnConsoleCtrl, TRUE); + const gao::app::AllInOneOutcome out = gao::app::run_all_in_one(hooks); + for (const std::string& line : gao::app::result_lines(out)) hooks.log(line); + if (!out.ok) return 1; + std::printf("Saved: `gao --apply` re-applies it, `gao --boot on` applies it at every logon.\n"); + if (const std::string note = gao::app::update_logon_copy(); !note.empty()) std::printf("%s\n", note.c_str()); + std::printf("Applied until reboot. `gao --reset` returns to stock.\n"); + return 0; +} + +// The memory bandwidth as a run measures it, and what the measurement looked +// like. after_load_s: that many seconds of the stress load first, on the same +// device, as the memory search measures right after a probe: the card is then +// warm and at whatever limit holds it back. Changes no setting. +int bandwidth(int after_load_s) { + gao::Stress load; + if (!load.Init()) { std::printf("stress init failed: %s\n", load.Error().c_str()); return 1; } + gao::Nvml nvml; + const bool telemetry = nvml.Init(); + auto card = [&](const char* when) { + if (!telemetry) return; + const gao::Telemetry t = nvml.Read(kGpu); + std::printf(" %s: core=%s MHz mem=%s MHz temp=%s C power=%s/%d W\n", when, gao::reading(t.core_mhz).c_str(), + gao::reading(t.mem_mhz).c_str(), gao::reading(t.temp_c).c_str(), gao::reading(t.power_w).c_str(), t.power_limit_w); + }; + if (after_load_s > 0) { + if (!telemetry) { std::printf("NVML init failed: %s\n", nvml.Error().c_str()); return 1; } + const gao::StabilityResult r = gao::run_stability([&] { return load.Batch(); }, [&] { return nvml.Read(kGpu); }, after_load_s, 85); + std::printf(" load: %s score=%.0f it/s %.1f s peak=%s C avg power=%s W avg core=%s MHz\n", gao::verdict_name(r.verdict), r.score, + r.seconds, gao::reading(r.peak_temp_c).c_str(), gao::reading(r.avg_power_w).c_str(), gao::reading(r.avg_core_mhz).c_str()); + if (r.verdict != gao::Verdict::Stable) return 1; + } + const auto gbps = load.MeasureBandwidth(); + card("after the measurement"); + std::string each; + for (const double run : load.BandwidthRuns()) { + char one[16] = " -"; // a run that was thrown away + if (run > 0) std::snprintf(one, sizeof(one), " %.1f", run); + each += one; + } + std::printf(" runs: %zu, %s\n each:%s\n", load.BandwidthRuns().size(), gao::bandwidth_runs_text(load.BandwidthRuns()).c_str(), each.c_str()); + if (!gbps) { std::printf("bandwidth measurement failed: %s\n", load.Error().c_str()); return 1; } + std::printf("memory bandwidth: %.1f GB/s (%s)\n", *gbps, load.AdapterName().c_str()); + return 0; +} + +// Measures what the optimize run assumes after a driver reset: how long the +// stress load takes to rebuild its device, and what the first batch on it +// costs. Changes no settings and needs no elevation, but it does load the card +// (two 3 s runs). Exit 0 when both runs are STABLE, the rebuild succeeded and +// the first batch after it computed without a lost device or a wrong value, +// 1 otherwise -- not --stress's 2 / 3 codes. +int stress_recreate() { + gao::Nvml nvml; + if (!nvml.Init()) { std::printf("NVML init failed: %s\n", nvml.Error().c_str()); return 1; } + gao::Stress load; + if (!load.Init()) { std::printf("stress init failed: %s\n", load.Error().c_str()); return 1; } + std::printf("stress-recreate: %s\n", load.AdapterName().c_str()); + + const auto run = [&](const char* label) { + const gao::StabilityResult r = gao::run_stability([&] { return load.Batch(); }, + [&] { return nvml.Read(kGpu); }, 3, 85); + std::printf("%s: %s score=%.0f it/s\n", label, gao::verdict_name(r.verdict), r.score); + return r.verdict == gao::Verdict::Stable; + }; + const bool first_ok = run("before"); + + const auto t0 = std::chrono::steady_clock::now(); + const bool recreated = load.Recreate(); + const double recreate_ms = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + if (!recreated) { + std::printf("recreate: failed after %.1f ms: %s\n", recreate_ms, load.Error().c_str()); + return 1; + } + std::printf("recreate: ok, %.1f ms\n", recreate_ms); + + const gao::StressBatch b = load.Batch(); + // A batch that lost the device or computed wrong has no time worth printing. + const bool batch_ok = !b.device_lost && b.wrong_values == 0; + if (b.device_lost) std::printf("first batch: DEVICE LOST\n"); + else if (b.wrong_values != 0) std::printf("first batch: %d wrong values\n", b.wrong_values); + else std::printf("first batch: %.1f ms, %lld iterations\n", b.elapsed_ms, b.iterations); + + const bool second_ok = run("after"); + return first_ok && batch_ok && second_ok ? 0 : 1; +} + +} // namespace gao::cli diff --git a/src/app/main.cpp b/src/app/main.cpp index 37e6aed..989caf7 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,29 +1,16 @@ #define WIN32_LEAN_AND_MEAN #define NOMINMAX #include +#include "app/cli_commands.hpp" #include "app/common.hpp" #include "app/guarded_gpu.hpp" -#include "core/stability.hpp" -#include "core/stress_math.hpp" #include "core/version.hpp" #include "core/vf_curve.hpp" -#include "hw/app_files.hpp" -#include "hw/boot_task.hpp" -#include "hw/gpu_control.hpp" -#include "hw/nvapi.hpp" -#include "hw/nvidia_app.hpp" -#include "hw/nvml.hpp" #include "hw/stress.hpp" -#include -#include -#include -#include +#include #include -#include #include -#include #include -#include #include // The whole argument must be an int: std::atoi would read a typo like @@ -34,538 +21,7 @@ static bool ParseIntArg(const char* text, int* out) { return ec == std::errc() && p == end && p != text; } -static int probe() { - gao::Nvml nvml; - if (!nvml.Init()) { - std::printf("NVML init failed: %s\n", nvml.Error().c_str()); - return 1; - } - const int count = nvml.DeviceCount(); - if (count < 0) { - std::printf("NVML device count failed: %s\n", nvml.Error().c_str()); - return 1; - } - std::printf("GPUs: %d\n", count); - for (int i = 0; i < count; ++i) { - const gao::Telemetry t = nvml.Read(static_cast(i)); - if (!t.ok) { std::printf(" [%d] read failed\n", i); continue; } - // Each field is -1 when the driver did not report it, even when t.ok. - std::printf(" [%d] core=%s MHz mem=%s MHz temp=%s C fan=%s power=%s/%d W\n", i, - gao::reading(t.core_mhz).c_str(), gao::reading(t.mem_mhz).c_str(), gao::reading(t.temp_c).c_str(), - gao::reading(t.fan_pct, "%").c_str(), gao::reading(t.power_w).c_str(), t.power_limit_w); - } - // The search's outer bound (core/search.hpp: search_bounds). - gao::Nvapi nvapi; - if (!nvapi.Init()) { - std::printf("offset ranges: unavailable (NVAPI init failed: %s)\n", nvapi.Error().c_str()); - return 0; - } - const auto ranges = nvapi.ReadOffsetRangesMhz(gao::app::kGpu); - if (!ranges) { - std::printf("offset ranges: unavailable (%s)\n", nvapi.Error().c_str()); - return 0; - } - std::printf("offset ranges as the driver reports them: core %+d to %+d MHz, mem %+d to %+d MHz\n", - ranges->core.min_mhz, ranges->core.max_mhz, ranges->mem.min_mhz, ranges->mem.max_mhz); - return 0; -} - -using gao::app::kGpu; - -// Shared by --set-core and --set-mem. Prints "requested X, read back Y" and -// OK/MISMATCH. Always prints the read-back, even on success -- the number -// being visible is the point, not just the verdict. -static int set_offset(const char* label, int mhz, bool core) { - gao::Nvapi nvapi; - if (!nvapi.Init()) { std::printf("NVAPI init failed: %s\n", nvapi.Error().c_str()); return 1; } - const bool ok = core ? nvapi.SetCoreOffsetMhz(kGpu, mhz) : nvapi.SetMemOffsetMhz(kGpu, mhz); - const auto readback = nvapi.ReadOffsetsMhz(kGpu); - std::printf("%s: requested %d MHz, read back ", label, mhz); - if (readback) std::printf("%d MHz\n", core ? readback->first : readback->second); - else std::printf("unavailable (%s)\n", nvapi.Error().c_str()); - // Print the diagnostic before the verdict: "clamped to - // 210 MHz" and "the call never reached an unelevated driver" both show - // MISMATCH here, and without this line they are indistinguishable. - if (!ok) std::printf("%s\n", nvapi.Error().c_str()); - std::printf("%s\n", ok ? "OK" : "MISMATCH"); - return ok ? 0 : 1; -} - -static void print_curve(const std::vector& curve) { - std::printf("voltage/frequency curve: %zu points, %d to %d mV\n", curve.size(), curve.front().volt_uv / 1000, - curve.back().volt_uv / 1000); - std::printf(" slot mV MHz raw offset\n"); - for (const gao::VfPoint& p : curve) - std::printf(" %4d %4d %6.1f %d\n", p.index, p.volt_uv / 1000, p.freq_khz / 1000.0, p.raw_offset); -} - -// Read-only: what the driver reports as the curve. Exit 1 when it cannot be read. -static int curve() { - gao::Nvapi nvapi; - if (!nvapi.Init()) { std::printf("NVAPI init failed: %s\n", nvapi.Error().c_str()); return 1; } - const auto points = nvapi.ReadVfCurve(kGpu); - if (!points) { - std::printf("curve: unavailable (%s)%s\n", nvapi.Error().c_str(), - gao::app::is_elevated() ? "" : "; reading it may need an administrator shell"); - return 1; - } - print_curve(*points); - return 0; -} - -// Writes a flat top by hand: the point at or just above runs and -// no point above it runs more; those are stored lower (0: a -// top that is exactly flat). With the flat top sits on that core -// offset, which is set first and stays on the points below the anchor. For -// the hardware checks; `gao --reset` undoes it. -static int curve_flatten(int mv, int mhz, int tail_drop_mhz, int core_mhz) { - const gao::app::TuningLock lock; - if (!lock.owned()) { std::printf("another optimize is already running (in the app or on the command line)\n"); return 1; } - // A running tray app would take the missing core offset for a driver - // reset and write its saved tune over the flat top: it is told to leave - // the card alone, as after `gao --reset`. - gao::app::tell_tray(gao::app::TrayNotice::StockByChoice); - gao::Nvml nvml; - gao::Nvapi nvapi; - if (!nvml.Init()) { std::printf("NVML init failed: %s\n", nvml.Error().c_str()); return 1; } - if (!nvapi.Init()) { std::printf("NVAPI init failed: %s\n", nvapi.Error().c_str()); return 1; } - const gao::GpuControl gpu = gao::make_gpu_control(nvml, nvapi, kGpu); - if (core_mhz != 0 && !gpu.set_core_offset(core_mhz)) { - std::printf("core offset %+d MHz: %s\n", core_mhz, nvapi.Error().c_str()); - return 1; - } - const auto before = gpu.read_vf_curve(); - if (!before) { std::printf("curve: unavailable (%s)\n", nvapi.Error().c_str()); return 1; } - int anchor = -1; - for (const gao::VfPoint& p : *before) // lowest voltage first - if (p.volt_uv >= mv * 1000) { anchor = p.index; break; } - if (anchor < 0) { std::printf("no curve point at or above %d mV\n", mv); return 1; } - const gao::VfApplyResult r = gao::apply_flat_top(gpu, anchor, mhz * 1000, tail_drop_mhz * 1000, core_mhz * 1000); - if (const auto after = gpu.read_vf_curve()) print_curve(*after); - if (!r.ok) { std::printf("not applied: %s\n", r.why.c_str()); return 1; } - std::printf("flat top applied: slot %d runs %d MHz, in %d write rounds -- OK\n", anchor, mhz, r.passes); - return 0; -} - -static int reset() { - // Stock by choice: tell a running tray app first, or a watchdog tick - // between the writes and the notice would take it for a TDR and re-apply. - gao::app::tell_tray(gao::app::TrayNotice::StockByChoice); - gao::Nvapi nvapi; - if (!nvapi.Init()) { std::printf("NVAPI init failed: %s\n", nvapi.Error().c_str()); return 1; } - // A flat top on the voltage/frequency curve first: the offset reset below - // is not known to remove one. - gao::GpuControl curve; - curve.read_vf_curve = [&nvapi] { return nvapi.ReadVfCurve(kGpu); }; - curve.write_vf_offsets = [&nvapi](const std::vector& offsets) { return nvapi.WriteVfRawOffsets(kGpu, offsets); }; - std::string why; - bool ok = gao::remove_vf_shape(curve, &why); - if (!ok) std::printf("curve: %s\n", why.c_str()); - ok = nvapi.ResetOffsets(kGpu) && ok; - const auto readback = nvapi.ReadOffsetsMhz(kGpu); - std::printf("reset: requested core 0 MHz, mem 0 MHz\n"); - if (readback) std::printf("read back core %d MHz, mem %d MHz\n", readback->first, readback->second); - else std::printf("read back unavailable (%s)\n", nvapi.Error().c_str()); - if (!ok) std::printf("%s\n", nvapi.Error().c_str()); - // Power limit back to the driver default, where the card supports it. - gao::Nvml nvml; - if (!nvml.Init()) { - std::printf("power limit: not restored (NVML: %s)\n", nvml.Error().c_str()); - ok = false; - } else if (nvml.PowerLimitRangePct(kGpu)) { - const bool power_ok = nvml.SetPowerLimitPct(kGpu, 100); - std::printf("power limit: default %s\n", power_ok ? "restored" : nvml.Error().c_str()); - ok = ok && power_ok; - } - std::printf("%s\n", ok ? "OK" : "MISMATCH"); - return ok ? 0 : 1; -} - -// Runs the DX11 stress load for `seconds` of GPU time, printing one line per -// second, then the verdict. Changes no settings, so Ctrl+C is always safe. -// duty_pct < 100: a light load, the card working that share of `seconds` of -// wall time and idle in between (duty_pause_ms); the lines then come per -// second of GPU time. -// Assumes the first NVIDIA DXGI adapter is NVML device kGpu -- true on a -// single-GPU machine, which is all this CLI supports (see kGpu). -static int stress(int seconds, int max_temp_c, gao::StressSelftest selftest, int duty_pct) { - gao::Nvml nvml; - if (!nvml.Init()) { std::printf("NVML init failed: %s\n", nvml.Error().c_str()); return 1; } - gao::Stress load; - if (!load.Init(selftest)) { std::printf("stress init failed: %s\n", load.Error().c_str()); return 1; } - std::printf("stress: %s, %d s, abort above %d C", load.AdapterName().c_str(), seconds, max_temp_c); - if (duty_pct < 100) std::printf(", working %d %% of the time", duty_pct); - std::printf("\n"); - const double gpu_seconds = duty_pct < 100 ? std::max(1.0, seconds * duty_pct / 100.0) : seconds; - - double t = 0, next_print = 1.0; - long long window_its = 0; - double window_s = 0; - auto batch = [&] { - const gao::StressBatch b = load.Batch(); - if (const int pause = gao::duty_pause_ms(b.elapsed_ms, duty_pct); pause > 0) - std::this_thread::sleep_for(std::chrono::milliseconds(pause)); - t += b.elapsed_ms / 1000.0; - window_s += b.elapsed_ms / 1000.0; - window_its += b.iterations; - return b; - }; - auto read = [&] { - const gao::Telemetry tel = nvml.Read(kGpu); - if (t >= next_print) { - std::printf(" t=%.0fs score=%.0f it/s core=%s MHz mem=%s MHz temp=%s C power=%s/%d W\n", t, - window_its / window_s, gao::reading(tel.core_mhz).c_str(), gao::reading(tel.mem_mhz).c_str(), - gao::reading(tel.temp_c).c_str(), gao::reading(tel.power_w).c_str(), tel.power_limit_w); - window_its = 0; - window_s = 0; - next_print = t + 1.0; - } - return tel; - }; - const gao::StabilityResult r = gao::run_stability(batch, read, gpu_seconds, max_temp_c); - std::printf("VERDICT: %s score=%.0f it/s %.1f s peak=%s C avg power=%s W avg core=%s MHz avg mem=%s MHz\n", - gao::verdict_name(r.verdict), r.score, r.seconds, gao::reading(r.peak_temp_c).c_str(), - gao::reading(r.avg_power_w).c_str(), gao::reading(r.avg_core_mhz).c_str(), - gao::reading(r.avg_mem_mhz).c_str()); - switch (r.verdict) { - case gao::Verdict::Stable: return 0; - case gao::Verdict::WrongResult: - case gao::Verdict::DeviceLost: - case gao::Verdict::Stalled: return 2; - case gao::Verdict::TooHot: - case gao::Verdict::NoTelemetry: return 3; - case gao::Verdict::Aborted: return 1; - } - return 1; -} - -static std::atomic g_abort{false}; -static const gao::GpuControl* g_gpu = nullptr; // set while --optimize runs - -// Ctrl+C / Ctrl+Break do not kill the process during --optimize: they ask -// the search to stop, and the search restores stock before returning. -// Closing the window, logoff and shutdown do kill it (Windows allows ~5 s), -// so those reset to stock right here instead of leaving a candidate applied. -static BOOL WINAPI OnConsoleCtrl(DWORD type) { - if (type == CTRL_C_EVENT || type == CTRL_BREAK_EVENT) { - g_abort = true; - std::printf("\nabort requested -- stopping the probe; the card goes back to stock, or to the tune it had before the run\n"); - return TRUE; - } - if (type == CTRL_CLOSE_EVENT || type == CTRL_LOGOFF_EVENT || type == CTRL_SHUTDOWN_EVENT) { - g_abort = true; - if (g_gpu && g_gpu->reset_to_stock) g_gpu->reset_to_stock(); - if (g_gpu && g_gpu->set_fan_auto) g_gpu->set_fan_auto(); - return TRUE; - } - return FALSE; -} - -// Exit 0: updated, or already the latest. 1: the check or the update failed. -static int update() { - const auto check = gao::app::check_for_update(); - if (!check.error.empty()) { std::printf("could not check for updates: %s\n", check.error.c_str()); return 1; } - if (!check.release) { std::printf("up to date: this is %s and there is no newer release\n", gao::kVersion.data()); return 0; } - std::printf("version %s is available (this is %s); downloading\n", check.release->version.c_str(), gao::kVersion.data()); - std::string message; - const bool ok = gao::app::install_update(*check.release, &message); - std::printf("%s\n", message.c_str()); - if (ok) std::printf("A running tray app keeps its old version until it is restarted.\n"); - return ok ? 0 : 1; -} - -// --busy-ok, as the last argument of a run: other programs may stay on the card. -static bool g_busy_ok = false; - -// Other programs use the card: the run says which, and does not start unless -// it was told that this is fine. -static gao::BusyAnswer on_busy(const std::vector& programs) { - if (g_busy_ok) return gao::BusyAnswer::GoOn; - std::printf("Other programs are using the graphics card: %s.\n" - "A run measures the card under full load, and what they take from it makes every number lower and less\n" - "steady: the result, and its comparison with stock. Close them and start again, or add --busy-ok as the\n" - "last argument to go on as it is.\n", gao::busy_text(programs).c_str()); - return gao::BusyAnswer::Stop; -} - -static int optimize(gao::Preset preset, const std::optional& fan_curve) { - gao::app::OptimizeHooks hooks; - hooks.aborted = [] { return g_abort.load(); }; - hooks.log = gao::app::logged([](const std::string& msg) { std::printf(" %s\n", msg.c_str()); }); - hooks.busy = on_busy; - hooks.active_gpu = [](const gao::GpuControl* gpu) { - g_gpu = gpu; - SetConsoleCtrlHandler(OnConsoleCtrl, gpu ? TRUE : FALSE); - }; - const auto out = gao::app::run_optimize(preset, hooks, fan_curve); - // Through the log: on screen and in run.log alike. - for (const std::string& line : gao::app::result_lines(out)) hooks.log(line); - if (!out.ran || !out.result.ok) return 1; - if (out.saved) { - std::printf("Saved: `gao --apply` re-applies it, `gao --boot on` applies it at every logon.\n"); - if (const std::string note = gao::app::update_logon_copy(); !note.empty()) std::printf("%s\n", note.c_str()); - } - std::printf("Applied until reboot. `gao --reset` returns to stock.\n"); - return out.saved ? 0 : 2; // 2: tuned and applied, but --apply and boot-apply cannot use it -} - -// Finds the undervolt, leaves it applied and saves it as the profile; on_overclock: on top of the saved tune, into it. -static int undervolt(bool on_overclock) { - gao::app::OptimizeHooks hooks; - hooks.aborted = [] { return g_abort.load(); }; - hooks.log = gao::app::logged([](const std::string& msg) { std::printf(" %s\n", msg.c_str()); }); - hooks.busy = on_busy; - hooks.active_gpu = [](const gao::GpuControl* gpu) { - g_gpu = gpu; - SetConsoleCtrlHandler(OnConsoleCtrl, gpu ? TRUE : FALSE); - }; - // A running tray app holds off for the whole run (the tuning lock), as - // for --optimize, and afterwards finds the profile this run saved. - const auto out = gao::app::run_undervolt(hooks, on_overclock); - for (const std::string& line : gao::app::result_lines(out)) hooks.log(line); - if (!out.ran || !out.result.ok) return 1; - if (out.saved) { - std::printf("Saved: `gao --apply` re-applies it, `gao --boot on` applies it at every logon.\n"); - if (const std::string note = gao::app::update_logon_copy(); !note.empty()) std::printf("%s\n", note.c_str()); - } - std::printf("Applied until reboot. `gao --reset` returns to stock.\n"); - return out.saved ? 0 : 2; // 2: found and applied, but --apply and boot-apply cannot use it -} - -// The saved tune, tested again on the driver that is installed now. -static int retest() { - gao::app::OptimizeHooks hooks; - hooks.aborted = [] { return g_abort.load(); }; - hooks.log = gao::app::logged([](const std::string& msg) { std::printf(" %s\n", msg.c_str()); }); - hooks.busy = on_busy; - hooks.active_gpu = [](const gao::GpuControl* gpu) { - g_gpu = gpu; - SetConsoleCtrlHandler(OnConsoleCtrl, gpu ? TRUE : FALSE); - }; - const gao::app::RetestOutcome out = gao::app::run_retest(hooks); - for (const std::string& line : gao::app::result_lines(out)) hooks.log(line); - return out.ok ? 0 : 1; -} - -// The all-in-one run: stock, overclock, undervolt on top, fans, final test. -static int all_in_one() { - gao::app::OptimizeHooks hooks; - hooks.aborted = [] { return g_abort.load(); }; - hooks.log = gao::app::logged([](const std::string& msg) { std::printf(" %s\n", msg.c_str()); }); - hooks.busy = on_busy; - hooks.active_gpu = [](const gao::GpuControl* gpu) { - g_gpu = gpu; - SetConsoleCtrlHandler(OnConsoleCtrl, gpu ? TRUE : FALSE); - }; - // The measuring steps have no clock candidate applied, but they run for - // minutes: Ctrl+C must stop them too, and end at stock. - SetConsoleCtrlHandler(OnConsoleCtrl, TRUE); - const gao::app::AllInOneOutcome out = gao::app::run_all_in_one(hooks); - for (const std::string& line : gao::app::result_lines(out)) hooks.log(line); - if (!out.ok) return 1; - std::printf("Saved: `gao --apply` re-applies it, `gao --boot on` applies it at every logon.\n"); - if (const std::string note = gao::app::update_logon_copy(); !note.empty()) std::printf("%s\n", note.c_str()); - std::printf("Applied until reboot. `gao --reset` returns to stock.\n"); - return 0; -} - -static int apply() { - if (!gao::app::is_elevated()) { std::printf("--apply needs an elevated (administrator) shell\n"); return 1; } - std::string why; - if (!gao::app::prepare_state(&why)) { std::printf("%s\n", why.c_str()); return 1; } - gao::Nvml nvml; - if (!nvml.Init()) { std::printf("NVML init failed: %s\n", nvml.Error().c_str()); return 1; } - gao::Nvapi nvapi; - if (!nvapi.Init()) { std::printf("NVAPI init failed: %s\n", nvapi.Error().c_str()); return 1; } - gao::Config cfg = gao::app::load_config(); - cfg.boot_strikes = 0; // strikes only gate boot-apply - const std::string driver = nvml.DriverVersion(); - const auto d = gao::decide_boot(cfg, driver, nvml.GpuUuid(kGpu)); - if (d != gao::BootDecision::Apply) { - std::printf("not applied: %s\n", gao::app::decision_text(d, cfg, driver).c_str()); - return 1; - } - const gao::GpuControl gpu = gao::make_gpu_control(nvml, nvapi, kGpu); - if (!gao::apply_profile(gpu, *cfg.profile, &why)) { std::printf("not applied: %s\n", why.c_str()); return 1; } - gao::app::tell_tray(gao::app::TrayNotice::TuneApplied); // a running tray keeps it applied from now on - std::printf("applied %s\nOK\n", gao::app::profile_text(*cfg.profile).c_str()); - return 0; -} - -static int boot(bool on) { - std::string message; - const bool ok = on ? gao::app::enable_boot(&message) : gao::app::disable_boot(&message); - std::printf("%s\n", message.c_str()); - return ok ? 0 : 1; -} - -static int status() { - const auto text = gao::read_file(gao::config_path()); - const gao::Config cfg = text ? gao::from_json(*text) : gao::Config{}; - if (cfg.profile) std::printf("profile: %s\n", gao::app::profile_text(*cfg.profile).c_str()); - else if (text) std::printf("profile: none valid in %s\n", gao::config_path().string().c_str()); - else std::printf("profile: none (run `gao --optimize`)\n"); - gao::Nvml nvml; - const std::string driver = nvml.Init() ? nvml.DriverVersion() : std::string(); - if (cfg.profile) { - std::printf("driver: %s (%s)\n", driver.empty() ? "unknown" : driver.c_str(), - !driver.empty() && driver == cfg.profile->driver ? "matches" : "CHANGED -- run `gao --optimize` again"); - const std::string gpu_id = nvml.GpuUuid(kGpu); - std::printf("gpu: %s\n", !gpu_id.empty() && gpu_id == cfg.profile->gpu - ? "matches the profile" : "DIFFERENT card or unknown -- run `gao --optimize` again"); - } - // What the driver reports right now, whoever set it. - gao::Nvapi nvapi; - const auto applied = nvapi.Init() ? gao::make_gpu_control(nvml, nvapi, kGpu).read_applied() : std::nullopt; - if (applied) - std::printf("applied: power %d %%, core %+d MHz, mem %+d MHz\n", applied->power_pct, applied->core_mhz, applied->mem_mhz); - else - std::printf("applied: unknown (could not read the driver)\n"); - // The offsets above say nothing about a flat top: the curve does. - if (cfg.profile && cfg.profile->undervolt) { - const auto points = nvapi.ReadVfCurve(kGpu); - const char* state = "unknown (the curve could not be read)"; - if (points) { - switch (gao::curve_state(*points, cfg.profile->undervolt->volt_uv, cfg.profile->core_mhz * 1000)) { - case gao::CurveState::FlatTop: state = "applied"; break; - case gao::CurveState::Stock: state = "not applied (the curve is at stock)"; break; - case gao::CurveState::Other: state = "not applied (the curve carries other offsets)"; break; - } - } - std::printf("undervolt: %s\n", state); - } - if (const auto fan = nvml.ReadFan(kGpu)) - std::printf("fans: %s\n", fan->manual ? ("manual " + std::to_string(fan->target_pct) + " %").c_str() : "driver control"); - if (const auto target = nvml.TempTarget(kGpu)) { - const auto usual = nvapi.TempTargetDefaultC(kGpu); - const std::string note = !usual ? std::string() : *usual == *target ? ", its default" : ", NOT its default of " + std::to_string(*usual) + " C"; - std::printf("temp target: %d C (the card's own: from there it slows itself down%s)\n", *target, note.c_str()); - } else { - std::printf("temp target: not reported by this card\n"); - } - const auto curve = gao::active_fan_curve(cfg); - std::printf("fan curve: %s", cfg.fan_control && curve ? "on" : "off"); - if (curve) { - if (curve->stop_below_c) std::printf(", driver below %d C", *curve->stop_below_c); - for (const gao::FanPoint& p : curve->points) std::printf(", %d C %d %%", p.temp_c, p.pct); - } - std::printf("\n"); - if (cfg.fan_min_pct > 0) std::printf("fan minimum: %d %% (learned: the fans stall below it)\n", cfg.fan_min_pct); - std::printf("boot-apply: %s\n", gao::boot_task_exists() ? "on (logon task registered)" : "off"); - // No run starts while it is on (app/common.cpp). - const std::optional nvidia = gao::nvidia_auto_tuning(); - std::printf("nvidia app: Automatic Tuning %s\n", - !nvidia ? "unknown (its setting could not be read)" : *nvidia ? "ON -- switch it off before a run" : "off"); - std::error_code ec; - if (!std::filesystem::exists(gao::installed_exe_path(), ec)) std::printf("boot copy: not installed\n"); - else if (gao::app::logon_copy_is_this_build()) std::printf("boot copy: up to date\n"); - else std::printf("boot copy: OUTDATED -- run `gao --boot on` to install this build\n"); - std::printf("strikes: %d of %d\n", cfg.boot_strikes, gao::kMaxBootStrikes); - const auto log = gao::read_lines(gao::boot_log_path()); - if (log && !log->empty()) std::printf("last boot: %s\n", log->back().c_str()); - return 0; -} - -// Emergency exit: every fan back to the driver, whatever set it. -static int fan_auto() { - gao::Nvml nvml; - if (!nvml.Init()) { std::printf("NVML init failed: %s\n", nvml.Error().c_str()); return 1; } - const bool ok = nvml.SetFanAuto(kGpu); - if (!ok) std::printf("%s\n", nvml.Error().c_str()); - std::printf("fans: %s\n", ok ? "driver control -- OK" : "MISMATCH"); - return ok ? 0 : 1; -} - -// The memory bandwidth as a run measures it, and what the measurement looked -// like. after_load_s: that many seconds of the stress load first, on the same -// device, as the memory search measures right after a probe: the card is then -// warm and at whatever limit holds it back. Changes no setting. -static int bandwidth(int after_load_s) { - gao::Stress load; - if (!load.Init()) { std::printf("stress init failed: %s\n", load.Error().c_str()); return 1; } - gao::Nvml nvml; - const bool telemetry = nvml.Init(); - auto card = [&](const char* when) { - if (!telemetry) return; - const gao::Telemetry t = nvml.Read(kGpu); - std::printf(" %s: core=%s MHz mem=%s MHz temp=%s C power=%s/%d W\n", when, gao::reading(t.core_mhz).c_str(), - gao::reading(t.mem_mhz).c_str(), gao::reading(t.temp_c).c_str(), gao::reading(t.power_w).c_str(), t.power_limit_w); - }; - if (after_load_s > 0) { - if (!telemetry) { std::printf("NVML init failed: %s\n", nvml.Error().c_str()); return 1; } - const gao::StabilityResult r = gao::run_stability([&] { return load.Batch(); }, [&] { return nvml.Read(kGpu); }, after_load_s, 85); - std::printf(" load: %s score=%.0f it/s %.1f s peak=%s C avg power=%s W avg core=%s MHz\n", gao::verdict_name(r.verdict), r.score, - r.seconds, gao::reading(r.peak_temp_c).c_str(), gao::reading(r.avg_power_w).c_str(), gao::reading(r.avg_core_mhz).c_str()); - if (r.verdict != gao::Verdict::Stable) return 1; - } - const auto gbps = load.MeasureBandwidth(); - card("after the measurement"); - std::string each; - for (const double run : load.BandwidthRuns()) { - char one[16] = " -"; // a run that was thrown away - if (run > 0) std::snprintf(one, sizeof(one), " %.1f", run); - each += one; - } - std::printf(" runs: %zu, %s\n each:%s\n", load.BandwidthRuns().size(), gao::bandwidth_runs_text(load.BandwidthRuns()).c_str(), each.c_str()); - if (!gbps) { std::printf("bandwidth measurement failed: %s\n", load.Error().c_str()); return 1; } - std::printf("memory bandwidth: %.1f GB/s (%s)\n", *gbps, load.AdapterName().c_str()); - return 0; -} - -// Measures what the optimize run assumes after a driver reset: how long the -// stress load takes to rebuild its device, and what the first batch on it -// costs. Changes no settings and needs no elevation, but it does load the card -// (two 3 s runs). Exit 0 when both runs are STABLE, the rebuild succeeded and -// the first batch after it computed without a lost device or a wrong value, -// 1 otherwise -- not --stress's 2 / 3 codes. -static int stress_recreate() { - gao::Nvml nvml; - if (!nvml.Init()) { std::printf("NVML init failed: %s\n", nvml.Error().c_str()); return 1; } - gao::Stress load; - if (!load.Init()) { std::printf("stress init failed: %s\n", load.Error().c_str()); return 1; } - std::printf("stress-recreate: %s\n", load.AdapterName().c_str()); - - const auto run = [&](const char* label) { - const gao::StabilityResult r = gao::run_stability([&] { return load.Batch(); }, - [&] { return nvml.Read(kGpu); }, 3, 85); - std::printf("%s: %s score=%.0f it/s\n", label, gao::verdict_name(r.verdict), r.score); - return r.verdict == gao::Verdict::Stable; - }; - const bool first_ok = run("before"); - - const auto t0 = std::chrono::steady_clock::now(); - const bool recreated = load.Recreate(); - const double recreate_ms = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); - if (!recreated) { - std::printf("recreate: failed after %.1f ms: %s\n", recreate_ms, load.Error().c_str()); - return 1; - } - std::printf("recreate: ok, %.1f ms\n", recreate_ms); - - const gao::StressBatch b = load.Batch(); - // A batch that lost the device or computed wrong has no time worth printing. - const bool batch_ok = !b.device_lost && b.wrong_values == 0; - if (b.device_lost) std::printf("first batch: DEVICE LOST\n"); - else if (b.wrong_values != 0) std::printf("first batch: %d wrong values\n", b.wrong_values); - else std::printf("first batch: %.1f ms, %lld iterations\n", b.elapsed_ms, b.iterations); - - const bool second_ok = run("after"); - return first_ok && batch_ok && second_ok ? 0 : 1; -} - -// A run Ctrl+C stopped, with nothing of it kept (exit 1), puts back the tune -// it found on the card: a tray app's watchdog would otherwise take the stock -// it left for a driver reset. The window's runs do the same (gui/worker.cpp). -template static int putting_back(Run run) { - const auto before = gao::app::tune_before_run(); - const int code = run(); - if (g_abort && code == 1) { - gao::app::OptimizeHooks hooks; - hooks.log = gao::app::logged([](const std::string& msg) { std::printf(" %s\n", msg.c_str()); }); - gao::app::put_back_after_stop(before, hooks); - } - return code; -} +using namespace gao::cli; int main(int argc, char** argv) { // Before anything loads a DLL: System32 only (the delay-loaded From 9dabff09f199024d16fc5c5494c476188d8f34c2 Mon Sep 17 00:00:00 2001 From: Bejeweld Date: Sun, 18 Oct 2026 11:27:13 +0200 Subject: [PATCH 05/10] docs: the code layout after the file splits Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 7 ++++--- docs/development.md | 13 ++++++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a18db9c..ed9e903 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,9 +25,10 @@ Everything we compile is `/W4 /WX`; fix warnings, do not suppress them. ## Layout -Two executables on one engine: `gao.exe` (CLI, `src/app/main.cpp`) and -`GpuAutoOptimizer.exe` (window + tray + watchdog, `src/app/gui/`). Logic both -need lives in `src/app/common.*`, never in either entry point. +Two executables on one engine: `gao.exe` (CLI, `src/app/main.cpp`, its commands +in `src/app/cli_*`) and `GpuAutoOptimizer.exe` (window + tray + watchdog, +`src/app/gui/`). Logic both need lives in `src/app/common*`, never in either +entry point. ## Rules the design depends on diff --git a/docs/development.md b/docs/development.md index d0b6fac..9caced5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -49,10 +49,17 @@ src/hw/ the only code that touches hardware or the OS state folders. nvidia_app.* whether the NVIDIA app's Automatic Tuning is on, read from the driver's settings database update_io.* what an update needs from Windows: HTTPS download, SHA-256, unzip, file version src/app/ - common.* logic both programs share: optimize, logon apply, apply-at-logon on/off - main.cpp gao.exe, the command line + common.hpp logic both programs share: optimize, logon apply, apply-at-logon on/off + common.cpp the process, locks, config and logs + common_*.cpp run steps, optimize, All in one, retest, results, logon apply (common_boot) + common_internal.hpp helpers the common_*.cpp files share + main.cpp gao.exe, the command line: entry point, dispatch, usage + cli_* the gao.exe commands: cli_hardware (card reads and writes), cli_runs (stress tests and runs) gui/ GpuAutoOptimizer.exe: window, tray, optimize worker thread, watchdog loop -tests/ doctest, core only + ui.* the frame and the navigation + ui_widgets.* widgets the pages share + ui_*.cpp one per page: dashboard, fan, optimize, results, about +tests/ doctest: core_tests (src/core/), app_tests (the app layer, without a GPU) third_party/ doctest, nlohmann/json, Dear ImGui: vendored as source, no package manager ``` From 2552e5f6643e8b5d80675854c02365eb0ef1a4f4 Mon Sep 17 00:00:00 2001 From: Bejeweld Date: Sun, 18 Oct 2026 11:30:53 +0200 Subject: [PATCH 06/10] test(undervolt): point the comment at common_run_steps.cpp Co-Authored-By: Claude Opus 5.5 --- tests/test_undervolt.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_undervolt.cpp b/tests/test_undervolt.cpp index 67435ad..dab987a 100644 --- a/tests/test_undervolt.cpp +++ b/tests/test_undervolt.cpp @@ -590,7 +590,7 @@ TEST_CASE("find_undervolt at a chosen clock: when the margin allows no more rais namespace { -// The card at stock as every run measures it first (app/common.cpp, measure_stock): five minutes +// The card at stock as every run measures it first (app/common_run_steps.cpp, measure_stock): five minutes // with the fans left to the NVIDIA driver, stopped at kStockTempC. StabilityResult stock_run(Verdict verdict, double seconds, int peak_temp_c) { StabilityResult s; From 5319b649c58d472e56c93150b0bdda78637d468b Mon Sep 17 00:00:00 2001 From: Bejeweld Date: Sun, 18 Oct 2026 11:31:49 +0200 Subject: [PATCH 07/10] refactor(cli): point the comment at common_run_steps.cpp Co-Authored-By: Claude Opus 5.5 --- src/app/cli_hardware.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/cli_hardware.cpp b/src/app/cli_hardware.cpp index cdd2df3..d700915 100644 --- a/src/app/cli_hardware.cpp +++ b/src/app/cli_hardware.cpp @@ -262,7 +262,7 @@ int status() { std::printf("\n"); if (cfg.fan_min_pct > 0) std::printf("fan minimum: %d %% (learned: the fans stall below it)\n", cfg.fan_min_pct); std::printf("boot-apply: %s\n", gao::boot_task_exists() ? "on (logon task registered)" : "off"); - // No run starts while it is on (app/common.cpp). + // No run starts while it is on (app/common_run_steps.cpp). const std::optional nvidia = gao::nvidia_auto_tuning(); std::printf("nvidia app: Automatic Tuning %s\n", !nvidia ? "unknown (its setting could not be read)" : *nvidia ? "ON -- switch it off before a run" : "off"); From 01392ac4578026ee95fe7068115bc32a660ec26c Mon Sep 17 00:00:00 2001 From: Bejeweld Date: Sun, 18 Oct 2026 11:32:36 +0200 Subject: [PATCH 08/10] test(search): name the file that tests the reconnect case Co-Authored-By: Claude Opus 5.5 --- tests/test_search.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_search.cpp b/tests/test_search.cpp index 3684fbd..865ccfc 100644 --- a/tests/test_search.cpp +++ b/tests/test_search.cpp @@ -334,7 +334,7 @@ TEST_CASE("the margin applies to the confirmed core edge") { TEST_CASE("without a reconnect a failing bandwidth measurement never raises memory") { // As it was before the reset budget: the scan ends at the first failed // measurement, here the one at +0. With a reconnect that case leaves the - // memory at stock as well, and says why (tested below). + // memory at stock as well, and says why (tested in test_search_reset_budget.cpp). Run run; run.card.bw_curve = [](int m) { return 500 + m * 0.1; }; run.card.bw_fails = true; From 38a5dec8cec38f3608cc2953cb8d719dd081052d Mon Sep 17 00:00:00 2001 From: Bejeweld Date: Sun, 18 Oct 2026 12:00:29 +0200 Subject: [PATCH 09/10] docs: name guarded_gpu and the icon sources in the layout Co-Authored-By: Claude Opus 5.5 --- docs/development.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/development.md b/docs/development.md index 9caced5..f2e2626 100644 --- a/docs/development.md +++ b/docs/development.md @@ -53,6 +53,7 @@ src/app/ common.cpp the process, locks, config and logs common_*.cpp run steps, optimize, All in one, retest, results, logon apply (common_boot) common_internal.hpp helpers the common_*.cpp files share + guarded_gpu.* driver calls that cannot take the process down, reconnected after a driver reset main.cpp gao.exe, the command line: entry point, dispatch, usage cli_* the gao.exe commands: cli_hardware (card reads and writes), cli_runs (stress tests and runs) gui/ GpuAutoOptimizer.exe: window, tray, optimize worker thread, watchdog loop @@ -61,6 +62,7 @@ src/app/ ui_*.cpp one per page: dashboard, fan, optimize, results, about tests/ doctest: core_tests (src/core/), app_tests (the app layer, without a GPU) third_party/ doctest, nlohmann/json, Dear ImGui: vendored as source, no package manager +docs/images/ the README's pictures; mark.svg (32 px and up) and mark-small.svg (16 to 24 px) are the sources of src/app/app.ico ``` ## Profiles as data From 7e7db3f9bdd44372a3ae765a67aeac944d17456c Mon Sep 17 00:00:00 2001 From: Bejeweld Date: Sun, 18 Oct 2026 12:00:29 +0200 Subject: [PATCH 10/10] chore: remove the unused first README hero image .github/assets/hero.svg is the README's hero from 2026-09-20; the README has used docs/images/hero.svg since, and nothing else references it. Co-Authored-By: Claude Opus 5.5 --- .github/assets/hero.svg | 81 ----------------------------------------- 1 file changed, 81 deletions(-) delete mode 100644 .github/assets/hero.svg diff --git a/.github/assets/hero.svg b/.github/assets/hero.svg deleted file mode 100644 index ac672d1..0000000 --- a/.github/assets/hero.svg +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - - GPU Auto Optimizer - Pick a profile, press Optimize. It finds the highest stable settings itself. - - - RISK PROFILE - - - Safe - - - Balanced - - - Performance - - - Extreme - - SEARCH CEILING FOR THIS PROFILE - core +150 MHz mem +800 MHz power -10% … +15% - every step applied, read back, then stress-tested before it is kept - - SETTINGS ARE VOLATILE - cleared on reboot — optional boot-apply stops itself after 3 failures - - - - - - - - optimizer log - - - backend NVAPI PState20 selected - baseline measured under CuPy load - core +180 froze — journaled, blacklisted - core +150 read-back ok · soak passed - mem +800 read-back ok · soak passed - power cap applied via NVML - - ✓ write-ahead journal: a freeze is never retried - - - - Windows · NVIDIA · NVAPI + NVML control · CuPy stability testing · 75 mocked unit tests - -