feat: thread benchmark, GPU-aware idle mining, thread scaling fix

- Add pool mining thread benchmark: cycles through thread counts with
  20s warmup + 10s measurement to find optimal setting for CPU
- Add GPU-aware idle detection: GPU utilization >= 10% (video, games)
  treats system as active; toggle in mining tab header (default: on)
  Supports AMD sysfs, NVIDIA nvidia-smi, Intel freq ratio; -1 on macOS
- Fix idle thread scaling: use getRequestedThreads() for immediate
  thread count instead of xmrig API threads_active which lags on restart
- Apply active thread count on initial mining start when user is active
- Skip idle mining adjustments while benchmark is running
- Disable thread grid drag-to-select during benchmark
- Add idle_gpu_aware setting with JSON persistence (default: true)
- Add 7 i18n English strings for benchmark and GPU-aware tooltips
This commit is contained in:
dan_s
2026-04-01 17:06:05 -05:00
parent b3d43ba0ad
commit 09f287fbc5
10 changed files with 508 additions and 9 deletions

View File

@@ -43,6 +43,81 @@ static int s_drag_anchor_thread = 0; // thread# where drag started
// Earnings filter: 0 = All, 1 = Solo, 2 = Pool
static int s_earnings_filter = 0;
// Thread benchmark state
struct ThreadBenchmark {
enum class Phase { Idle, Starting, WarmingUp, Measuring, Advancing, Done };
Phase phase = Phase::Idle;
std::vector<int> candidates;
int current_index = 0;
struct Result {
int threads;
double hashrate;
};
std::vector<Result> results;
float phase_timer = 0.0f;
static constexpr float WARMUP_SECS = 20.0f;
static constexpr float MEASURE_SECS = 10.0f;
double best_sample = 0.0; // best hashrate_10s during current measurement window
int sample_count = 0; // number of non-zero hashrate samples collected
int optimal_threads = 0;
double optimal_hashrate = 0.0;
bool was_pool_running = false;
int prev_threads = 0;
void reset() {
phase = Phase::Idle;
candidates.clear();
current_index = 0;
results.clear();
phase_timer = 0.0f;
best_sample = 0.0;
sample_count = 0;
optimal_threads = 0;
optimal_hashrate = 0.0;
was_pool_running = false;
prev_threads = 0;
}
void buildCandidates(int max_threads) {
candidates.clear();
if (max_threads <= 16) {
for (int t = 1; t <= max_threads; t++)
candidates.push_back(t);
} else {
// Sample: 1, then every ceil(max/10) step, always including max
int step = std::max(1, (max_threads + 9) / 10);
for (int t = 1; t <= max_threads; t += step)
candidates.push_back(t);
if (candidates.back() != max_threads)
candidates.push_back(max_threads);
}
}
float totalEstimatedSecs() const {
return (float)candidates.size() * (WARMUP_SECS + MEASURE_SECS);
}
float elapsedSecs() const {
float completed = (float)current_index * (WARMUP_SECS + MEASURE_SECS);
return completed + phase_timer;
}
float progress() const {
float total = totalEstimatedSecs();
return (total > 0.0f) ? std::min(1.0f, elapsedSecs() / total) : 0.0f;
}
};
static ThreadBenchmark s_benchmark;
bool IsMiningBenchmarkActive() {
return s_benchmark.phase != ThreadBenchmark::Phase::Idle &&
s_benchmark.phase != ThreadBenchmark::Phase::Done;
}
// Pool mode state
static bool s_pool_mode = false;
static char s_pool_url[256] = "pool.dragonx.is:3433";
@@ -162,9 +237,16 @@ static void RenderMiningTabContent(App* app)
}
// Sync thread grid with actual count when idle thread scaling adjusts threads
if (app->settings()->getMineWhenIdle() && app->settings()->getIdleThreadScaling() && !s_drag_active) {
if (s_pool_mode && state.pool_mining.xmrig_running && state.pool_mining.threads_active > 0) {
s_selected_threads = std::min(state.pool_mining.threads_active, max_threads);
// Skip during benchmark — the benchmark controls thread counts directly
if (app->settings()->getMineWhenIdle() && app->settings()->getIdleThreadScaling() && !s_drag_active && !IsMiningBenchmarkActive()) {
if (s_pool_mode && state.pool_mining.xmrig_running) {
// Use the requested thread count (available immediately) rather
// than threads_active from the xmrig API which lags during restarts.
int reqThreads = app->getXmrigRequestedThreads();
if (reqThreads > 0)
s_selected_threads = std::min(reqThreads, max_threads);
else if (state.pool_mining.threads_active > 0)
s_selected_threads = std::min(state.pool_mining.threads_active, max_threads);
} else if (mining.generate && mining.genproclimit > 0) {
s_selected_threads = std::min(mining.genproclimit, max_threads);
}
@@ -239,6 +321,84 @@ static void RenderMiningTabContent(App* app)
? state.pool_mining.xmrig_running
: (mining.generate || state.pool_mining.xmrig_running);
// ================================================================
// Thread Benchmark state machine — runs pool mining at each candidate
// thread count to find the optimal setting for this CPU.
// ================================================================
if (s_benchmark.phase != ThreadBenchmark::Phase::Idle &&
s_benchmark.phase != ThreadBenchmark::Phase::Done) {
float dt = ImGui::GetIO().DeltaTime;
s_benchmark.phase_timer += dt;
switch (s_benchmark.phase) {
case ThreadBenchmark::Phase::Starting:
// Start pool mining at current candidate
if (s_benchmark.current_index < (int)s_benchmark.candidates.size()) {
int t = s_benchmark.candidates[s_benchmark.current_index];
app->stopPoolMining();
app->startPoolMining(t);
s_benchmark.phase = ThreadBenchmark::Phase::WarmingUp;
s_benchmark.phase_timer = 0.0f;
s_benchmark.best_sample = 0.0;
s_benchmark.sample_count = 0;
} else {
s_benchmark.phase = ThreadBenchmark::Phase::Done;
}
break;
case ThreadBenchmark::Phase::WarmingUp:
if (s_benchmark.phase_timer >= ThreadBenchmark::WARMUP_SECS) {
s_benchmark.phase = ThreadBenchmark::Phase::Measuring;
s_benchmark.phase_timer = 0.0f;
s_benchmark.best_sample = 0.0;
s_benchmark.sample_count = 0;
}
break;
case ThreadBenchmark::Phase::Measuring:
// Sample hashrate during measurement window
if (state.pool_mining.hashrate_10s > 0.0) {
s_benchmark.sample_count++;
if (state.pool_mining.hashrate_10s > s_benchmark.best_sample)
s_benchmark.best_sample = state.pool_mining.hashrate_10s;
}
if (s_benchmark.phase_timer >= ThreadBenchmark::MEASURE_SECS) {
int t = s_benchmark.candidates[s_benchmark.current_index];
s_benchmark.results.push_back({t, s_benchmark.best_sample});
if (s_benchmark.best_sample > s_benchmark.optimal_hashrate) {
s_benchmark.optimal_hashrate = s_benchmark.best_sample;
s_benchmark.optimal_threads = t;
}
s_benchmark.phase = ThreadBenchmark::Phase::Advancing;
s_benchmark.phase_timer = 0.0f;
}
break;
case ThreadBenchmark::Phase::Advancing:
app->stopPoolMining();
s_benchmark.current_index++;
if (s_benchmark.current_index < (int)s_benchmark.candidates.size()) {
s_benchmark.phase = ThreadBenchmark::Phase::Starting;
} else {
// Done — apply optimal thread count
s_benchmark.phase = ThreadBenchmark::Phase::Done;
if (s_benchmark.optimal_threads > 0) {
s_selected_threads = s_benchmark.optimal_threads;
app->settings()->setPoolThreads(s_selected_threads);
app->settings()->save();
}
// Restart mining if it was running before, using optimal count
if (s_benchmark.was_pool_running && s_benchmark.optimal_threads > 0) {
app->startPoolMining(s_benchmark.optimal_threads);
}
}
break;
default:
break;
}
}
// ================================================================
// Proportional section budget — ensures all content fits without
// scrolling at the minimum window size (1024×775).
@@ -936,6 +1096,41 @@ static void RenderMiningTabContent(App* app)
idleRightEdge = sBtnX - 4.0f * dp;
}
// GPU-aware idle toggle (to the left, when idle is on)
// When ON (default): GPU utilization >= 10% counts as "not idle"
// When OFF: unrestricted mode, only keyboard/mouse input matters
if (idleOn) {
bool gpuAware = app->settings()->getIdleGpuAware();
const char* gpuIcon = gpuAware ? ICON_MD_MONITOR : ICON_MD_MONITOR;
float gBtnX = idleRightEdge - btnSz;
float gBtnY = btnY;
if (gpuAware) {
dl->AddRectFilled(ImVec2(gBtnX, gBtnY), ImVec2(gBtnX + btnSz, gBtnY + btnSz),
WithAlpha(Primary(), 40), btnSz * 0.5f);
}
ImVec2 gIcoSz = icoFont->CalcTextSizeA(icoFont->LegacySize, FLT_MAX, 0, gpuIcon);
ImU32 gIcoCol = gpuAware ? Primary() : OnSurfaceDisabled();
dl->AddText(icoFont, icoFont->LegacySize,
ImVec2(gBtnX + (btnSz - gIcoSz.x) * 0.5f, gBtnY + (btnSz - gIcoSz.y) * 0.5f),
gIcoCol, gpuIcon);
ImGui::SetCursorScreenPos(ImVec2(gBtnX, gBtnY));
ImGui::InvisibleButton("##IdleGpuAware", ImVec2(btnSz, btnSz));
if (ImGui::IsItemClicked()) {
app->settings()->setIdleGpuAware(!gpuAware);
app->settings()->save();
}
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", gpuAware
? TR("mining_idle_gpu_on_tooltip")
: TR("mining_idle_gpu_off_tooltip"));
}
idleRightEdge = gBtnX - 4.0f * dp;
}
// Idle delay combo (to the left, when idle is enabled and NOT in thread scaling mode)
if (idleOn && !threadScaling) {
struct DelayOption { int seconds; const char* label; };
@@ -1076,6 +1271,145 @@ static void RenderMiningTabContent(App* app)
ImGui::SetCursorScreenPos(savedCur);
}
// --- Thread Benchmark button / progress (left of idle toggle) ---
{
ImVec2 benchSavedCur = ImGui::GetCursorScreenPos();
bool benchRunning = s_benchmark.phase != ThreadBenchmark::Phase::Idle &&
s_benchmark.phase != ThreadBenchmark::Phase::Done;
bool benchDone = s_benchmark.phase == ThreadBenchmark::Phase::Done;
ImFont* icoFont = Type().iconSmall();
if (benchRunning) {
// Show progress bar + current test info
float barW = std::min(180.0f * hs, idleRightEdge - (cardMin.x + pad) - 10.0f * dp);
float barH = 4.0f * dp;
float barX = idleRightEdge - barW;
float barY = curY + headerH - barH - 2.0f * dp;
// Progress bar track
dl->AddRectFilled(ImVec2(barX, barY), ImVec2(barX + barW, barY + barH),
WithAlpha(OnSurface(), 30), barH * 0.5f);
// Progress bar fill
float pct = s_benchmark.progress();
dl->AddRectFilled(ImVec2(barX, barY), ImVec2(barX + barW * pct, barY + barH),
Primary(), barH * 0.5f);
// Status text above bar
int ct = s_benchmark.current_index < (int)s_benchmark.candidates.size()
? s_benchmark.candidates[s_benchmark.current_index] : 0;
snprintf(buf, sizeof(buf), "%s %d/%d (%dt)",
TR("mining_benchmark_testing"),
s_benchmark.current_index + 1,
(int)s_benchmark.candidates.size(), ct);
ImVec2 txtSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(barX + (barW - txtSz.x) * 0.5f, barY - txtSz.y - 2.0f * dp),
OnSurfaceMedium(), buf);
// Cancel button (small X)
float cancelSz = icoFont->LegacySize + 4.0f * dp;
float cancelX = barX - cancelSz - 4.0f * dp;
float cancelY = curY + (headerH - cancelSz) * 0.5f;
ImGui::SetCursorScreenPos(ImVec2(cancelX, cancelY));
ImGui::InvisibleButton("##BenchCancel", ImVec2(cancelSz, cancelSz));
if (ImGui::IsItemClicked()) {
app->stopPoolMining();
if (s_benchmark.was_pool_running)
app->startPoolMining(s_benchmark.prev_threads);
s_benchmark.reset();
}
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", TR("mining_benchmark_cancel"));
}
const char* cancelIcon = ICON_MD_CLOSE;
ImVec2 cIcoSz = icoFont->CalcTextSizeA(icoFont->LegacySize, FLT_MAX, 0, cancelIcon);
dl->AddText(icoFont, icoFont->LegacySize,
ImVec2(cancelX + (cancelSz - cIcoSz.x) * 0.5f,
cancelY + (cancelSz - cIcoSz.y) * 0.5f),
OnSurfaceMedium(), cancelIcon);
idleRightEdge = cancelX - 4.0f * dp;
} else if (benchDone && s_benchmark.optimal_threads > 0) {
// Show result briefly, then reset on next click
snprintf(buf, sizeof(buf), "%s: %dt (%.1f H/s)",
TR("mining_benchmark_result"),
s_benchmark.optimal_threads, s_benchmark.optimal_hashrate);
ImVec2 txtSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
float txtX = idleRightEdge - txtSz.x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(txtX, curY + (headerH - txtSz.y) * 0.5f),
WithAlpha(Success(), 220), buf);
// Dismiss button
float dismissSz = icoFont->LegacySize + 4.0f * dp;
float dismissX = txtX - dismissSz - 4.0f * dp;
float dismissY = curY + (headerH - dismissSz) * 0.5f;
ImGui::SetCursorScreenPos(ImVec2(dismissX, dismissY));
ImGui::InvisibleButton("##BenchDismiss", ImVec2(dismissSz, dismissSz));
if (ImGui::IsItemClicked())
s_benchmark.reset();
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", TR("mining_benchmark_dismiss"));
}
const char* okIcon = ICON_MD_CHECK;
ImVec2 oIcoSz = icoFont->CalcTextSizeA(icoFont->LegacySize, FLT_MAX, 0, okIcon);
dl->AddText(icoFont, icoFont->LegacySize,
ImVec2(dismissX + (dismissSz - oIcoSz.x) * 0.5f,
dismissY + (dismissSz - oIcoSz.y) * 0.5f),
WithAlpha(Success(), 200), okIcon);
idleRightEdge = dismissX - 4.0f * dp;
} else if (s_pool_mode) {
// Show benchmark button (only in pool mode)
float btnSz = icoFont->LegacySize + 8.0f * dp;
float btnX = idleRightEdge - btnSz;
float btnY = curY + (headerH - btnSz) * 0.5f;
ImGui::SetCursorScreenPos(ImVec2(btnX, btnY));
ImGui::InvisibleButton("##BenchStart", ImVec2(btnSz, btnSz));
bool benchHovered = ImGui::IsItemHovered();
bool benchClicked = ImGui::IsItemClicked();
// Hover highlight
if (benchHovered) {
dl->AddRectFilled(ImVec2(btnX, btnY), ImVec2(btnX + btnSz, btnY + btnSz),
StateHover(), btnSz * 0.5f);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("%s", TR("mining_benchmark_tooltip"));
}
const char* benchIcon = ICON_MD_SPEED;
ImVec2 bIcoSz = icoFont->CalcTextSizeA(icoFont->LegacySize, FLT_MAX, 0, benchIcon);
dl->AddText(icoFont, icoFont->LegacySize,
ImVec2(btnX + (btnSz - bIcoSz.x) * 0.5f,
btnY + (btnSz - bIcoSz.y) * 0.5f),
OnSurfaceMedium(), benchIcon);
if (benchClicked) {
// Require a wallet address for pool mining
std::string worker(s_pool_worker);
if (!worker.empty()) {
s_benchmark.reset();
s_benchmark.was_pool_running = state.pool_mining.xmrig_running;
s_benchmark.prev_threads = s_selected_threads;
s_benchmark.buildCandidates(max_threads);
s_benchmark.phase = ThreadBenchmark::Phase::Starting;
// Stop any active solo mining first
if (mining.generate)
app->stopMining();
}
}
idleRightEdge = btnX - 4.0f * dp;
}
ImGui::SetCursorScreenPos(benchSavedCur);
}
// Active mining indicator (left of idle toggle)
if (mining.generate) {
float pulse = effects::isLowSpecMode()
@@ -1115,11 +1449,13 @@ static void RenderMiningTabContent(App* app)
}
// Show pointer cursor when hovering the thread grid
if (hovered_thread > 0)
bool benchActive = s_benchmark.phase != ThreadBenchmark::Phase::Idle &&
s_benchmark.phase != ThreadBenchmark::Phase::Done;
if (hovered_thread > 0 && !benchActive)
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
// Drag-to-select logic
if (ImGui::IsMouseClicked(0) && hovered_thread > 0) {
// Drag-to-select logic (disabled during benchmark)
if (!benchActive && ImGui::IsMouseClicked(0) && hovered_thread > 0) {
// Begin drag
s_drag_active = true;
s_drag_anchor_thread = hovered_thread;

View File

@@ -15,5 +15,11 @@ namespace ui {
*/
void RenderMiningTab(App* app);
/**
* @brief Returns true when the thread benchmark is actively running.
* Used by idle mining to avoid interfering with measurements.
*/
bool IsMiningBenchmarkActive();
} // namespace ui
} // namespace dragonx