perf: memoize per-frame render hot paths (console, transactions, recent lists)

Immediate-mode render functions re-run every frame; these rebuilt O(N)
state each time even when nothing changed. From a 6-lens perf audit, each
finding verified on a hot-path basis:

- Console: ConsoleModel gains revision(); the full-model filter scan and the
  glyph-by-glyph text-layout pass (BuildConsoleLayout) rebuild only when the
  model / filter / wrap-width / zoom change — previously it re-shaped up to
  10,000 lines every frame even when idle/scrolled. clear() force-invalidates
  the memo mid-render (no OOB on the just-emptied visible set).
- Transactions: the summary-card totals memoize behind the tab's existing
  FNV-1a fingerprint (also folds away a now-duplicate O(N) display-key pass).
- Send / Receive recent lists: early-exit the prefix scan (state.transactions
  is kept newest-first) instead of filtering the whole tx history every frame.
- network_refresh_service: O(new x total) txid find-and-replace -> hash map.
- Sidebar unconfirmed-tx badge cached on last_tx_update + tx count; the
  daemon-memory probe (/proc scan on Linux, popen on macOS) throttled to ~1.5s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 22:55:04 -05:00
co-authored by Claude Opus 4.8
parent 0a042df8e0
commit d9fa00bb38
9 changed files with 238 additions and 104 deletions
+20 -5
View File
@@ -1806,7 +1806,10 @@ void App::render()
// long confirmed (the other leg holds the real count). So: a txid with ANY confirmed leg is
// confirmed, and we count UNIQUE unconfirmed txids — otherwise the badge sticks on stale 0-conf
// legs of already-confirmed transactions and double-counts multi-leg ones.
{
// Recompute only when the tx list actually changed (last_tx_update is bumped on every tx refresh;
// size() catches same-second content changes). Otherwise this built two unordered_sets over the whole
// wallet tx list every frame just to size a badge.
if (state_.last_tx_update != sb_unconf_key_ts_ || state_.transactions.size() != sb_unconf_key_n_) {
std::unordered_set<std::string> confirmedTxids;
for (const auto& tx : state_.transactions) {
if (tx.confirmations >= 1) confirmedTxids.insert(tx.txid);
@@ -1817,8 +1820,11 @@ void App::render()
unconfirmedTxids.insert(tx.txid);
}
}
sbStatus.unconfirmedTxCount = static_cast<int>(unconfirmedTxids.size());
sb_unconf_count_ = static_cast<int>(unconfirmedTxids.size());
sb_unconf_key_ts_ = state_.last_tx_update;
sb_unconf_key_n_ = state_.transactions.size();
}
sbStatus.unconfirmedTxCount = sb_unconf_count_;
// Sidebar minimum height from ui.toml schema (DPI-scaled).
const float sbMinHeight = sbde("min-height", 360.0f);
@@ -5695,17 +5701,26 @@ bool App::stopDaemonForBootstrap()
double App::getDaemonMemoryUsageMB() const
{
// The Mining tab reads this every frame, but the probe is expensive (Linux embedded/external paths
// scan /proc; macOS forks a shell). Throttle to ~1.5s so a 60Hz caller doesn't hammer the OS.
const double now = ImGui::GetTime();
if (daemon_mem_probe_at_ > 0.0 && now - daemon_mem_probe_at_ < 1.5)
return daemon_mem_cached_mb_;
daemon_mem_probe_at_ = now;
double result = 0.0;
// If we have an embedded daemon with a tracked process handle, use it
// directly — more reliable than a process scan since we own the handle.
if (daemon_controller_ && daemon_controller_->isRunning()) {
double mb = daemon_controller_->memoryUsageMB();
daemon_mem_diag_ = "embedded";
if (mb > 0.0) return mb;
result = (mb > 0.0) ? mb : util::Platform::getDaemonMemoryUsageMB();
} else {
daemon_mem_diag_ = "process scan";
result = util::Platform::getDaemonMemoryUsageMB(); // external daemon
}
// Fall back to platform-level process scan (external daemon)
return util::Platform::getDaemonMemoryUsageMB();
daemon_mem_cached_mb_ = result;
return result;
}
// ============================================================================