perf(history): memoize the transaction display list instead of rebuilding it every frame

The History tab rebuilt its entire display list on every render frame: indexing all
transactions by txid, merging autoshield send+receive pairs into "shield" rows, and
std::sort-ing the result — O(N log N) plus several heap allocations at ~60fps, only to
show one 50-row page. The data is already sorted newest-first by the refresh service,
so the per-frame sort was redundant on top.

Memoize the merged+sorted list, rebuilding only when the underlying transactions
actually change. The cache key is a cheap, allocation-free FNV-1a fingerprint over the
display-relevant fields (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; otherwise (the common read/scroll case) the cache is reused.
Filtering, search, and pagination still run per-frame over the cached list (cheap linear
scans that depend on interactive state).

Also document that App::shouldRefreshTransactions() is block-height/dirty driven (not
interval-gated) — the Transactions timer only paces the check; the recent-poll handles
between-block mempool/unconfirmed deltas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 22:24:58 -05:00
parent 255d9399fa
commit 560f2bcf91
2 changed files with 45 additions and 1 deletions

View File

@@ -716,6 +716,11 @@ void App::setCurrentPage(ui::NavPage page)
bool App::shouldRefreshTransactions() const bool App::shouldRefreshTransactions() const
{ {
// NOTE: this is block-height / dirty driven, NOT interval-gated. It returns true only when a new
// block arrived (currentBlocks != last_tx_block_height_), the history was never fetched, or
// something marked it dirty (tab entry, a send, a reorg, etc.). The Transactions timer only
// controls how often this CHECK runs; between blocks the lightweight recent-poll
// (shouldRefreshRecentTransactions / TxAge) handles mempool + unconfirmed deltas instead.
const int currentBlocks = state_.sync.blocks; const int currentBlocks = state_.sync.blocks;
return network_refresh_.shouldRefreshTransactions(last_tx_block_height_, return network_refresh_.shouldRefreshTransactions(last_tx_block_height_,
currentBlocks, currentBlocks,

View File

@@ -21,9 +21,11 @@
#include "imgui.h" #include "imgui.h"
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <cstdint>
#include <ctime> #include <ctime>
#include <cmath> #include <cmath>
#include <unordered_map> #include <unordered_map>
#include <vector>
namespace dragonx { namespace dragonx {
namespace ui { namespace ui {
@@ -369,8 +371,37 @@ void RenderTransactionsTab(App* app)
// Two entries sharing the same txid where one is "send" and // Two entries sharing the same txid where one is "send" and
// one is "receive" to a z-address are combined into a single // one is "receive" to a z-address are combined into a single
// "shield" entry. We first index by txid, then build the list. // "shield" entry. We first index by txid, then build the list.
std::vector<DisplayTx> display_txns; //
// 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.
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));
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;
}
if (!s_display_cache_valid || displayKey != s_display_cache_key) {
s_display_cache.clear();
std::vector<DisplayTx>& display_txns = s_display_cache;
// Map txid -> indices in state.transactions // Map txid -> indices in state.transactions
std::unordered_map<std::string, std::vector<size_t>> txid_map; std::unordered_map<std::string, std::vector<size_t>> txid_map;
for (size_t i = 0; i < state.transactions.size(); i++) { for (size_t i = 0; i < state.transactions.size(); i++) {
@@ -445,8 +476,16 @@ void RenderTransactionsTab(App* app)
if (aPending != bPending) return aPending; if (aPending != bPending) return aPending;
return a.timestamp > b.timestamp; return a.timestamp > b.timestamp;
}); });
s_display_cache_key = displayKey;
s_display_cache_valid = true;
} }
// The merged + sorted list (rebuilt above only on change). Filtering/search/pagination below
// run per-frame over this cache — they're cheap (a linear scan) and depend on interactive
// filter/search state, so they are intentionally not memoized.
const std::vector<DisplayTx>& display_txns = s_display_cache;
// Apply type + search filters // Apply type + search filters
std::vector<size_t> filtered_indices; std::vector<size_t> filtered_indices;
for (size_t i = 0; i < display_txns.size(); i++) { for (size_t i = 0; i < display_txns.size(); i++) {