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
+109 -62
View File
@@ -23,6 +23,7 @@
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstring>
#include <ctime>
#include <cmath>
#include <unordered_map>
@@ -186,53 +187,106 @@ void RenderTransactionsTab(App* app)
// Summary Cards — Received | Sent | Mined
// ================================================================
{
int recvCount = 0, sendCount = 0, minedCount = 0;
double recvTotal = 0.0, sendTotal = 0.0, minedTotal = 0.0;
// Identify autoshield legs (same txid with a "send" leg and a "receive"-to-z leg):
// that pair is a single internal shielding move — shown as one "Shield" row in the
// list below — not income or spending. Counting both legs double-counts the amount
// into BOTH the Sent and Received totals, so the cards disagree with the list.
// Mirror the list's pairing logic and exclude both legs from the totals.
std::unordered_map<std::string, std::vector<size_t>> summaryTxidMap;
for (size_t i = 0; i < state.transactions.size(); i++)
summaryTxidMap[state.transactions[i].txid].push_back(i);
std::vector<bool> isShieldLeg(state.transactions.size(), false);
for (const auto& kv : summaryTxidMap) {
if (kv.second.size() < 2) continue;
int send_i = -1, recv_i = -1;
for (size_t si : kv.second) {
const auto& stx = state.transactions[si];
if (stx.type == "send" && send_i < 0) send_i = (int)si;
else if (stx.type == "receive" && recv_i < 0 &&
!stx.address.empty() && stx.address[0] == 'z') recv_i = (int)si;
}
if (send_i >= 0 && recv_i >= 0) {
isShieldLeg[send_i] = true;
isShieldLeg[recv_i] = true;
// The list shows the merged shield under the "Sent" filter, so count it there too —
// otherwise the Sent card reads 0 while the Sent list is non-empty. Use the shielded
// (receive-leg) amount, which is what the merged row displays.
sendCount++;
sendTotal += std::abs(state.transactions[recv_i].amount);
// Content fingerprint over state.transactions, shared by the summary-card totals (below) and
// the display-list memoization (further down). This is a cheap, allocation-free FNV-1a hash
// computed ONCE per frame and folds in every field the *totals* and the *displayed rows*
// depend on: tx count, last update time, and per-tx txid (drives autoshield pairing),
// amount (drives the totals — bit-exact), confirmations, timestamp, type, and address. It is
// sort-independent (s_sort_mode is NOT folded here); the display key mixes s_sort_mode on top
// so a sort change re-sorts the display cache without invalidating the totals (which don't
// depend on order). NB vs. the old display key: this additionally folds full txid + amount +
// full type/address (not just first char), which the summary totals require — the old key
// omitted them, so it could not have gated the totals correctly.
std::uint64_t contentKey;
{
std::uint64_t h = 1469598103934665603ULL; // FNV-1a offset basis
auto mix = [&h](std::uint64_t v) { h = (h ^ v) * 1099511628211ULL; };
auto mixBytes = [&mix](const std::string& s) {
mix(s.size());
for (unsigned char c : s) mix(static_cast<std::uint64_t>(c));
};
mix(state.transactions.size());
mix(static_cast<std::uint64_t>(state.last_tx_update));
for (const auto& t : state.transactions) {
mixBytes(t.txid);
std::uint64_t amtBits = 0;
std::memcpy(&amtBits, &t.amount, sizeof(double)); // bit-exact fold of the double
mix(amtBits);
mix(static_cast<std::uint64_t>(t.confirmations));
mix(static_cast<std::uint64_t>(t.timestamp));
mixBytes(t.type);
mixBytes(t.address);
}
contentKey = h;
}
for (size_t i = 0; i < state.transactions.size(); i++) {
if (isShieldLeg[i]) continue; // internal shielding move — not received or sent
const auto& tx = state.transactions[i];
if (tx.type == "receive") {
recvCount++;
recvTotal += std::abs(tx.amount);
} else if (tx.type == "send") {
sendCount++;
sendTotal += std::abs(tx.amount);
} else if (tx.type == "generate" || tx.type == "immature" || tx.type == "mined") {
minedCount++;
minedTotal += std::abs(tx.amount);
// Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
// numbers that only change when the tx list changes, so memoize them behind contentKey and
// recompute only when it moves. File-scope-style function statics persist across wallet
// switches, but a switch/refresh mutates state.transactions (count, txids, amounts …), which
// bumps contentKey, so the sentinel initial key + change detection covers that for free.
static int s_recvCount = 0, s_sendCount = 0, s_minedCount = 0;
static double s_recvTotal = 0.0, s_sendTotal = 0.0, s_minedTotal = 0.0;
static std::uint64_t s_summaryKey = 0; // sentinel; first frame always recomputes
if (contentKey != s_summaryKey) {
int recvCount = 0, sendCount = 0, minedCount = 0;
double recvTotal = 0.0, sendTotal = 0.0, minedTotal = 0.0;
// Identify autoshield legs (same txid with a "send" leg and a "receive"-to-z leg):
// that pair is a single internal shielding move — shown as one "Shield" row in the
// list below — not income or spending. Counting both legs double-counts the amount
// into BOTH the Sent and Received totals, so the cards disagree with the list.
// Mirror the list's pairing logic and exclude both legs from the totals.
std::unordered_map<std::string, std::vector<size_t>> summaryTxidMap;
for (size_t i = 0; i < state.transactions.size(); i++)
summaryTxidMap[state.transactions[i].txid].push_back(i);
std::vector<bool> isShieldLeg(state.transactions.size(), false);
for (const auto& kv : summaryTxidMap) {
if (kv.second.size() < 2) continue;
int send_i = -1, recv_i = -1;
for (size_t si : kv.second) {
const auto& stx = state.transactions[si];
if (stx.type == "send" && send_i < 0) send_i = (int)si;
else if (stx.type == "receive" && recv_i < 0 &&
!stx.address.empty() && stx.address[0] == 'z') recv_i = (int)si;
}
if (send_i >= 0 && recv_i >= 0) {
isShieldLeg[send_i] = true;
isShieldLeg[recv_i] = true;
// The list shows the merged shield under the "Sent" filter, so count it there too —
// otherwise the Sent card reads 0 while the Sent list is non-empty. Use the shielded
// (receive-leg) amount, which is what the merged row displays.
sendCount++;
sendTotal += std::abs(state.transactions[recv_i].amount);
}
}
for (size_t i = 0; i < state.transactions.size(); i++) {
if (isShieldLeg[i]) continue; // internal shielding move — not received or sent
const auto& tx = state.transactions[i];
if (tx.type == "receive") {
recvCount++;
recvTotal += std::abs(tx.amount);
} else if (tx.type == "send") {
sendCount++;
sendTotal += std::abs(tx.amount);
} else if (tx.type == "generate" || tx.type == "immature" || tx.type == "mined") {
minedCount++;
minedTotal += std::abs(tx.amount);
}
}
s_recvCount = recvCount; s_recvTotal = recvTotal;
s_sendCount = sendCount; s_sendTotal = sendTotal;
s_minedCount = minedCount; s_minedTotal = minedTotal;
s_summaryKey = contentKey;
}
// Render from the cached totals (identical values — only the recomputation is skipped).
const int recvCount = s_recvCount, sendCount = s_sendCount, minedCount = s_minedCount;
const double recvTotal = s_recvTotal, sendTotal = s_sendTotal, minedTotal = s_minedTotal;
float availWidth = ImGui::GetContentRegionAvail().x;
float cardGap = cGap;
float cardW = (availWidth - 2 * cardGap) / 3.0f;
@@ -346,31 +400,24 @@ void RenderTransactionsTab(App* app)
//
// This merge + sort is O(N log N) with several heap allocations, so it is MEMOIZED: it only
// rebuilds when the underlying transactions actually change, not every frame. The cache key
// is a cheap, allocation-free FNV-1a fingerprint over the fields that affect the displayed
// rows (count, last update time, and each tx's confirmations / timestamp / type+address
// first char). A new block bumps every confirmation, so the key changes and we rebuild;
// between changes (the common case while the user reads/scrolls) we reuse the cache. The
// result is already sorted newest-first by the refresh service, but we re-sort here to apply
// the "pending first" ordering — also folded into the memoized build.
// reuses the shared per-frame contentKey computed above (a cheap, allocation-free FNV-1a
// fingerprint over count / last update time / every tx's txid+amount+confirmations+timestamp+
// type+address) and mixes in s_sort_mode on top, so the display cache also rebuilds on a sort
// change. contentKey already folds in more than the display list strictly needs (amount,
// full txid/type/address) — a superset, so any change that would have flipped the old
// per-first-char key still flips this one. A new block bumps every confirmation, so the key
// changes and we rebuild; between changes (the common case while the user reads/scrolls) we
// reuse the cache. The result is already sorted newest-first by the refresh service, but we
// re-sort here to apply the "pending first" ordering — also folded into the memoized build.
static std::vector<DisplayTx> s_display_cache;
static std::uint64_t s_display_cache_key = 0;
static bool s_display_cache_valid = false;
std::uint64_t displayKey;
{
std::uint64_t h = 1469598103934665603ULL; // FNV-1a offset basis
auto mix = [&h](std::uint64_t v) { h = (h ^ v) * 1099511628211ULL; };
mix(state.transactions.size());
mix(static_cast<std::uint64_t>(state.last_tx_update));
mix(static_cast<std::uint64_t>(s_sort_mode)); // re-sort the cache when the mode changes
for (const auto& t : state.transactions) {
mix(static_cast<std::uint64_t>(t.confirmations));
mix(static_cast<std::uint64_t>(t.timestamp));
mix(t.type.empty() ? 0u : static_cast<unsigned char>(t.type[0]));
mix(t.address.empty() ? 0u : static_cast<unsigned char>(t.address[0]));
}
displayKey = h;
}
// Derive the display key from the shared contentKey (avoids a second full pass over the
// transactions) plus the sort mode — the only display-affecting input contentKey omits.
std::uint64_t displayKey =
(contentKey ^ (static_cast<std::uint64_t>(s_sort_mode) + 0x9E3779B97F4A7C15ULL))
* 1099511628211ULL;
if (!s_display_cache_valid || displayKey != s_display_cache_key) {
s_display_cache.clear();