From d9fa00bb38d19a5cddb28780c280d5fc9b785779 Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 1 Sep 2026 22:55:04 -0500 Subject: [PATCH] perf: memoize per-frame render hot paths (console, transactions, recent lists) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app.cpp | 25 +++- src/services/network_refresh_service.cpp | 44 +++--- src/ui/windows/console_model.cpp | 3 + src/ui/windows/console_model.h | 6 + src/ui/windows/console_tab.cpp | 42 +++++- src/ui/windows/console_tab.h | 9 ++ src/ui/windows/receive_tab.cpp | 21 +-- src/ui/windows/send_tab.cpp | 21 +-- src/ui/windows/transactions_tab.cpp | 171 +++++++++++++++-------- 9 files changed, 238 insertions(+), 104 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index ee66ec5..4e87716 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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 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(unconfirmedTxids.size()); + sb_unconf_count_ = static_cast(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; } // ============================================================================ diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index 0763966..2d79484 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -1108,6 +1108,22 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe ? &hushChatReceivedOutputs : nullptr; + // Index result.transactions by (txid, type) once so the two replace-loops below + // can find-and-replace in O(1) instead of a nested linear scan over the full + // (potentially thousands-large) tx list on every 'recent' refresh cycle. The map + // holds the index of the FIRST occurrence of each key (preserving the linear + // scan's break-on-first-match), and is kept in sync on every append so a later + // entry in the same cycle still finds an earlier appended one — exactly as the + // re-scanned vector did before. + auto txKey = [](const TransactionInfo& tx) { + return tx.txid + '\x1f' + tx.type; + }; + std::unordered_map byKey; + byKey.reserve(result.transactions.size()); + for (std::size_t i = 0; i < result.transactions.size(); ++i) { + byKey.emplace(txKey(result.transactions[i]), i); // keep first-occurrence index + } + try { std::set recentTxids; std::vector recentTransactions; @@ -1115,15 +1131,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses); for (auto& recent : recentTransactions) { - bool replaced = false; - for (auto& existing : result.transactions) { - if (existing.txid == recent.txid && existing.type == recent.type) { - existing = recent; - replaced = true; - break; - } + auto it = byKey.find(txKey(recent)); + if (it != byKey.end()) { + result.transactions[it->second] = recent; + } else { + byKey.emplace(txKey(recent), result.transactions.size()); + result.transactions.push_back(std::move(recent)); } - if (!replaced) result.transactions.push_back(std::move(recent)); } } catch (const std::exception& e) { DEBUG_LOGF("recent listtransactions error: %s\n", e.what()); @@ -1149,15 +1163,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe snapshot.miningAddresses, hushChatReceivedOutputsPtr); for (auto& scanned : scannedTransactions) { - bool replaced = false; - for (auto& existing : result.transactions) { - if (existing.txid == scanned.txid && existing.type == scanned.type) { - existing = scanned; - replaced = true; - break; - } + auto it = byKey.find(txKey(scanned)); + if (it != byKey.end()) { + result.transactions[it->second] = scanned; + } else { + byKey.emplace(txKey(scanned), result.transactions.size()); + result.transactions.push_back(std::move(scanned)); } - if (!replaced) result.transactions.push_back(std::move(scanned)); } if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight; ++result.shieldedAddressesScanned; diff --git a/src/ui/windows/console_model.cpp b/src/ui/windows/console_model.cpp index c4de6dc..a17d085 100644 --- a/src/ui/windows/console_model.cpp +++ b/src/ui/windows/console_model.cpp @@ -37,18 +37,21 @@ ConsoleModel::DrainResult ConsoleModel::drain() lines_.pop_front(); ++result.popped; } + ++revision_; // deque changed — lets the view memoize its filter/layout passes return result; } void ConsoleModel::clear() { lines_.clear(); + ++revision_; } bool ConsoleModel::toggleCollapsed(std::size_t i) { if (i >= lines_.size() || lines_[i].foldSpan <= 0) return false; lines_[i].collapsed = !lines_[i].collapsed; + ++revision_; // fold change alters which lines are visible return lines_[i].collapsed; } diff --git a/src/ui/windows/console_model.h b/src/ui/windows/console_model.h index ba25497..aa402f2 100644 --- a/src/ui/windows/console_model.h +++ b/src/ui/windows/console_model.h @@ -22,6 +22,7 @@ #include "console_channel.h" #include +#include #include #include #include @@ -76,11 +77,16 @@ public: const ConsoleModelLine& operator[](std::size_t i) const { return lines_[i]; } const ConsoleModelLine& back() const { return lines_.back(); } + // Monotonic counter bumped whenever the visible deque changes (drain added/evicted lines, clear, + // fold toggle). The view memoizes its per-frame filter + text-layout passes against this. + std::uint64_t revision() const { return revision_; } + private: const std::size_t max_lines_; std::deque lines_; // visible model — main thread only std::vector pending_; // guarded by ingest_mutex_ std::mutex ingest_mutex_; + std::uint64_t revision_ = 0; }; } // namespace ui diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index c5b618e..88c7c62 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -820,11 +820,23 @@ void ConsoleTab::renderOutput() // segment records which bytes of the source text appear on that visual row, so // hit-testing and selection highlight can map screen positions to exact char offsets. float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX); - ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize()); - layout_ = BuildConsoleLayout( - static_cast(visible_indices_.size()), - [this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; }, - wrap_width, line_height, interLineGap, measure); + // Memoize the text-shaping pass: rebuild only when the visible set, wrap width, line height, gap or + // zoom changed. Otherwise this re-wrapped and re-measured every visible line (glyph-by-glyph) every + // frame, even while idle/scrolled. layout_ is a member, so the cached geometry stays valid for + // drawVisibleLines / screenToTextPos when we skip the rebuild. + std::uint64_t layoutKey = vis_generation_ * 1000003ull; + layoutKey = layoutKey * 131ull + static_cast(wrap_width * 16.0f); + layoutKey = layoutKey * 131ull + static_cast(line_height * 16.0f); + layoutKey = layoutKey * 131ull + static_cast(interLineGap * 16.0f); + layoutKey = layoutKey * 131ull + static_cast(s_console_zoom * 1000.0f); + if (layoutKey != layout_key_) { + ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize()); + layout_ = BuildConsoleLayout( + static_cast(visible_indices_.size()), + [this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; }, + wrap_width, line_height, interLineGap, measure); + layout_key_ = layoutKey; + } // Mouse/keyboard interaction (wheel-up detach, selection drag, Ctrl+C/A). Raw IO bypasses // the child window's event consumption. @@ -891,6 +903,20 @@ void ConsoleTab::renderOutput() void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLower) { + // Memoize: rebuild the visible set only when the model changed or the filter state changed. + // Otherwise this scanned the entire (up to 10k-line) model with a per-line filter predicate every + // frame. The out-params + filter_match_count_/folding_active_ are members that stay valid until the + // key moves, so an early return leaves last frame's (still-correct) results in place. + std::uint64_t visKey = model_.revision() * 1000003ull; + for (const char* p = filter_text_; *p; ++p) visKey = visKey * 131ull + static_cast(*p); + visKey = visKey * 2ull + (s_daemon_messages_enabled ? 1u : 0u); + visKey = visKey * 2ull + (s_errors_only_enabled ? 1u : 0u); + visKey = visKey * 2ull + (s_rpc_trace_enabled ? 1u : 0u); + visKey = visKey * 2ull + (s_app_messages_enabled ? 1u : 0u); + if (visKey == vis_key_) return; // nothing that affects the visible set changed + vis_key_ = visKey; + ++vis_generation_; // the layout pass keys off this + ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled, s_errors_only_enabled, s_rpc_trace_enabled, s_app_messages_enabled}; @@ -2079,6 +2105,12 @@ void ConsoleTab::clear() // line indices) here to avoid an out-of-bounds crash. computeVisibleLines() rebuilds them next frame. visible_indices_.clear(); selection_.clear(); + // Force both memoized passes to rebuild: renderOutput() runs later THIS frame against the now-empty + // visible_indices_, and computeVisibleLines() recomputes next frame — without these resets the layout + // memo would skip the rebuild and keep stale geometry for the just-emptied set. + vis_key_ = ~0ull; + layout_key_ = ~0ull; + ++vis_generation_; stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing addLine(TR("console_cleared"), ConsoleChannel::Info); } diff --git a/src/ui/windows/console_tab.h b/src/ui/windows/console_tab.h index 13a64d2..f7b8607 100644 --- a/src/ui/windows/console_tab.h +++ b/src/ui/windows/console_tab.h @@ -15,6 +15,7 @@ #include "../../rpc/rpc_client.h" #include "../../rpc/rpc_worker.h" +#include #include #include #include @@ -188,6 +189,14 @@ private: bool has_text_filter_ = false; // computed once per frame (before the toolbar draws it) std::string filter_lower_; // lowercased filter needle for match highlighting + // Memoization keys so the two expensive per-frame passes rebuild only on change (not every frame): + // computeVisibleLines (filter scan over the whole model) is keyed on the model revision + filter + // state; BuildConsoleLayout (glyph-by-glyph text shaping of every visible line) is keyed on the + // resulting visible-set generation + wrap width + line height/zoom. + std::uint64_t vis_key_ = ~0ull; // key of the last computeVisibleLines + std::uint64_t vis_generation_ = 0; // bumped whenever visible_indices_ is rebuilt + std::uint64_t layout_key_ = ~0ull; // key of the last BuildConsoleLayout + // Wrap layout for the visible lines (segments + per-line heights + cumulative Y), // recomputed each frame by the pure BuildConsoleLayout (console_text_layout.h) and // consumed by the renderer + hit-testing. diff --git a/src/ui/windows/receive_tab.cpp b/src/ui/windows/receive_tab.cpp index b15c86d..7e8bbef 100644 --- a/src/ui/windows/receive_tab.cpp +++ b/src/ui/windows/receive_tab.cpp @@ -370,20 +370,25 @@ static void RenderRecentReceived(const AddressInfo& /* addr */, S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs); ImU32 recvCol = Success(); - // Collect matching transactions - std::vector recvs; - for (const auto& tx : state.transactions) { - if (tx.type != "receive" && tx.type != "mined") continue; - recvs.push_back(&tx); - } - // Grow the list to fill the dead space beneath the (fixed-height) receive card: // fit as many newest-first rows as the remaining region can show, instead of a // fixed 4. The child scrolls if the real history exceeds what fits, so nothing is // lost. A floor of 4 keeps the section substantial when the region is short. + // Compute maxRows BEFORE the collect loop so the scan can stop early (below). float listH = std::max(rowH, ImGui::GetContentRegionAvail().y); size_t maxRows = std::max(4, (size_t)std::floor(listH / rowH)); - if (recvs.size() > maxRows) recvs.resize(maxRows); // newest-first + + // Collect matching transactions. state.transactions is newest-first, so the first + // maxRows matches ARE exactly the rows we render — stop scanning once we have them + // instead of filtering the entire history every frame (mirrors RenderSharedRecentTx + // in balance_components.cpp). + std::vector recvs; + recvs.reserve(maxRows); + for (const auto& tx : state.transactions) { + if (tx.type != "receive" && tx.type != "mined") continue; + recvs.push_back(&tx); + if (recvs.size() >= maxRows) break; // newest-first: prefix is all we show + } ImGui::BeginChild("##RecentReceivedRows", ImVec2(width, listH), false, ImGuiWindowFlags_NoBackground); diff --git a/src/ui/windows/send_tab.cpp b/src/ui/windows/send_tab.cpp index 5d0ab8d..8766eb4 100644 --- a/src/ui/windows/send_tab.cpp +++ b/src/ui/windows/send_tab.cpp @@ -1120,20 +1120,25 @@ static void RenderRecentSends(const WalletState& state, float width, ImFont* cap S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs); ImU32 sendCol = Error(); - // Collect matching transactions - std::vector sends; - for (const auto& tx : state.transactions) { - if (tx.type != "send" && tx.type != "shield") continue; - sends.push_back(&tx); - } - // Grow the list to fill the dead space beneath the (fixed-height) compose card: // fit as many newest-first rows as the remaining region can show, instead of a // fixed 4. The child scrolls if the real history exceeds what fits, so nothing is // lost. A floor of 4 keeps the section substantial when the region is short. + // Row budget is hoisted above the collect loop (it only needs the remaining region + // height + row height, no per-row state) so the scan can early-exit. float listH = std::max(rowH, ImGui::GetContentRegionAvail().y); size_t maxRows = std::max(4, (size_t)std::floor(listH / rowH)); - if (sends.size() > maxRows) sends.resize(maxRows); // newest-first + + // Collect matching transactions. state.transactions is newest-first, so scan only a + // bounded prefix and stop once maxRows matches are gathered (mirrors the early-exit in + // balance_components.cpp:RenderSharedRecentTx) instead of filtering the whole history. + std::vector sends; + sends.reserve(maxRows); + for (const auto& tx : state.transactions) { + if (tx.type != "send" && tx.type != "shield") continue; + sends.push_back(&tx); + if (sends.size() == maxRows) break; // newest-first: enough rows to fill the region + } ImGui::BeginChild("##RecentSendRows", ImVec2(width, listH), false, ImGuiWindowFlags_NoBackground); diff --git a/src/ui/windows/transactions_tab.cpp b/src/ui/windows/transactions_tab.cpp index 0bba8ef..3312cc7 100644 --- a/src/ui/windows/transactions_tab.cpp +++ b/src/ui/windows/transactions_tab.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -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> summaryTxidMap; - for (size_t i = 0; i < state.transactions.size(); i++) - summaryTxidMap[state.transactions[i].txid].push_back(i); - std::vector 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(c)); + }; + mix(state.transactions.size()); + mix(static_cast(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(t.confirmations)); + mix(static_cast(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> summaryTxidMap; + for (size_t i = 0; i < state.transactions.size(); i++) + summaryTxidMap[state.transactions[i].txid].push_back(i); + std::vector 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 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(state.last_tx_update)); - mix(static_cast(s_sort_mode)); // re-sort the cache when the mode changes - for (const auto& t : state.transactions) { - mix(static_cast(t.confirmations)); - mix(static_cast(t.timestamp)); - mix(t.type.empty() ? 0u : static_cast(t.type[0])); - mix(t.address.empty() ? 0u : static_cast(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(s_sort_mode) + 0x9E3779B97F4A7C15ULL)) + * 1099511628211ULL; if (!s_display_cache_valid || displayKey != s_display_cache_key) { s_display_cache.clear();