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:
@@ -716,6 +716,11 @@ void App::setCurrentPage(ui::NavPage page)
|
||||
|
||||
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;
|
||||
return network_refresh_.shouldRefreshTransactions(last_tx_block_height_,
|
||||
currentBlocks,
|
||||
|
||||
@@ -21,9 +21,11 @@
|
||||
#include "imgui.h"
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <cmath>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
@@ -369,8 +371,37 @@ void RenderTransactionsTab(App* app)
|
||||
// Two entries sharing the same txid where one is "send" and
|
||||
// one is "receive" to a z-address are combined into a single
|
||||
// "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
|
||||
std::unordered_map<std::string, std::vector<size_t>> txid_map;
|
||||
for (size_t i = 0; i < state.transactions.size(); i++) {
|
||||
@@ -445,8 +476,16 @@ void RenderTransactionsTab(App* app)
|
||||
if (aPending != bPending) return aPending;
|
||||
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
|
||||
std::vector<size_t> filtered_indices;
|
||||
for (size_t i = 0; i < display_txns.size(); i++) {
|
||||
|
||||
Reference in New Issue
Block a user