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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user