IBD/sync speedups: parallel RandomX pre-verify, adaptive dbcache, P2P download fixes

- Parallel RandomX PoW pre-verification pool (CCheckQueue) run ahead of the serial
  connect; consensus-neutral (inline CheckRandomXSolution fallback still verifies
  anything not pre-verified). New -randomxverifythreads (default = -par).
- Adaptive dbcache: default sizes the UTXO/coins cache to most of RAM and shrinks
  under memory pressure, always leaving a reserve free; -dbcache pins a fixed value.
- P2P block download: bounded socket recv-drain loop (tlsmanager); frontier-block
  reassignment to break head-of-line stalls (-blockreassigntimeout); ProcessGetData
  serves a bounded batch of blocks per pass instead of one (fixes the serve-side
  one-block-per-tick throttle that caps download network-wide).
- assumeutxo: dumptxoutset RPC + LoadSnapshot machinery + AssumeutxoData chainparams.
- Signed bootstrap verification (util/bootstrap-dragonx.sh, util/sign-bootstrap.md).
- gtest: RandomX pre-verify consensus-equivalence test + UTXO-snapshot round-trip;
  revived the gtest harness (Makefile.am include fix, Makefile.gtest.include).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 12:30:10 -05:00
parent 2b011d6ee2
commit 1673cfb6dc
18 changed files with 1599 additions and 154 deletions

View File

@@ -85,10 +85,12 @@ void hush_pricesupdate(int32_t height,CBlock *pblock);
BlockMap mapBlockIndex;
CChain chainActive;
CBlockIndex *pindexBestHeader = NULL;
int nAssumeutxoSnapshotHeight = -1; // height H of a loaded UTXO snapshot; reorgs below H are refused (-1 = none)
static int64_t nTimeBestReceived = 0;
CWaitableCriticalSection csBestBlock;
CConditionVariable cvBlockChange;
int nScriptCheckThreads = 0;
int nRandomXVerifyThreads = 0; // parallel RandomX pre-verification worker count (0 = inline only)
bool fExperimentalMode = true;
bool fImporting = false;
bool fReindex = false;
@@ -485,7 +487,7 @@ namespace {
/** Update pindexLastCommonBlock and add not-in-flight missing successors to vBlocks, until it has
* at most count entries. */
void FindNextBlocksToDownload(NodeId nodeid, unsigned int count, std::vector<CBlockIndex*>& vBlocks, NodeId& nodeStaller) {
void FindNextBlocksToDownload(NodeId nodeid, unsigned int count, std::vector<CBlockIndex*>& vBlocks, NodeId& nodeStaller, CBlockIndex** pFrontierStuck = NULL) {
if (count == 0)
return;
@@ -562,8 +564,9 @@ namespace {
return;
}
} else if (waitingfor == -1) {
// This is the first already-in-flight block.
// This is the first already-in-flight block (the download frontier).
waitingfor = mapBlocksInFlight[pindex->GetBlockHash()].first;
if (pFrontierStuck) *pFrontierStuck = pindex;
}
}
}
@@ -4140,6 +4143,45 @@ static void PruneBlockIndexCandidates() {
assert(!setBlockIndexCandidates.empty());
}
// Activate a trusted UTXO snapshot (assumeutxo-style) as the chain tip WITHOUT replaying blocks
// 0..H. The chainstate has already been populated by CCoinsViewDB::LoadSnapshot(); here we mark the
// snapshot's base block (height H) as fully validated and set it as the active tip. Blocks above H
// then connect normally with full PoW + script + Sapling-proof validation. Requires that the block
// HEADERS for height H are already present in mapBlockIndex (from prior header sync or bootstrap).
// NOTE: below-H blocks have no body/undo data, so reorgs below H are impossible (see Stage D guard).
bool LoadSnapshotChainstate(const CUTXOSnapshotHeader& header, std::string& strError)
{
LOCK(cs_main);
BlockMap::iterator it = mapBlockIndex.find(header.baseBlockHash);
if (it == mapBlockIndex.end() || it->second == NULL) {
strError = "block header for the snapshot height is not present; sync headers (or use the bootstrap) before loading a UTXO snapshot";
return false;
}
CBlockIndex* pindexH = it->second;
if (pindexH->GetHeight() != header.nHeight) {
strError = "snapshot base block height does not match its header index";
return false;
}
// Only nChainTx is consensus-relevant for tip selection; nTx must merely be non-zero so the
// (nChainTx != 0) candidate-eligibility checks hold. Ancestors legitimately have nTx==0 here
// because we never received their bodies — this is the assumeutxo trust assumption.
if (pindexH->nTx == 0)
pindexH->nTx = (header.nChainTx > 0 ? (unsigned int)header.nChainTx : 1);
pindexH->nChainTx = (unsigned int)header.nChainTx;
if (header.fHasChainSaplingValue)
pindexH->nChainSaplingValue = header.nChainSaplingValue;
pindexH->RaiseValidity(BLOCK_VALID_SCRIPTS);
nAssumeutxoSnapshotHeight = pindexH->GetHeight(); // arm the reorg-below-H guard (Stage D)
setBlockIndexCandidates.insert(pindexH);
chainActive.SetTip(pindexH);
if (pindexBestHeader == NULL || pindexBestHeader->GetHeight() < pindexH->GetHeight())
pindexBestHeader = pindexH;
PruneBlockIndexCandidates();
return true;
}
/**
* Try to make some progress towards making pindexMostWork the active block.
* pblock is either NULL or a pointer to a CBlock corresponding to pindexMostWork.
@@ -4188,6 +4230,16 @@ static bool ActivateBestChainStep(bool fSkipdpow, CValidationState &state, CBloc
return state.DoS(100, error("ActivateBestChainStep(): pindexOldTip->GetHeight().%d > notarizedht %d && pindexFork->GetHeight().%d is < notarizedht %d, so ignore it",(int32_t)pindexOldTip->GetHeight(),notarizedht,(int32_t)pindexFork->GetHeight(),notarizedht),
REJECT_INVALID, "past-notarized-height");
}
// Refuse reorgs whose fork point is below a loaded UTXO snapshot height (Stage D): the node has
// no block/undo data for 0..H, so disconnecting below H is impossible. Belt-and-suspenders on top
// of checkpoint fork-rejection (H sits at/below the last hardcoded checkpoint).
if ( nAssumeutxoSnapshotHeight >= 0 && pindexFork != 0 && pindexFork->GetHeight() < nAssumeutxoSnapshotHeight )
{
return state.DoS(100, error("ActivateBestChainStep(): reorg fork height %d is below the loaded UTXO snapshot height %d; refusing",
(int32_t)pindexFork->GetHeight(), nAssumeutxoSnapshotHeight),
REJECT_INVALID, "below-assumeutxo-snapshot");
}
// - On ChainDB initialization, pindexOldTip will be null, so there are no removable blocks.
// - If pindexMostWork is in a chain that doesn't have the same genesis block as our chain,
// then pindexFork will be null, and we would need to remove the entire chain including
@@ -4258,6 +4310,36 @@ static bool ActivateBestChainStep(bool fSkipdpow, CValidationState &state, CBloc
}
nHeight = nTargetHeight;
// Parallel RandomX pre-verification (Stage 4): verify this about-to-be-connected window's
// PoW on the worker pool BEFORE the serial connect, so ConnectBlock rarely pays the
// ~tens-of-ms light-mode hash. Pure optimization — CheckBlockHeader's inline
// CheckRandomXSolution still verifies anything not pre-verified, so consensus is unchanged.
// We hold cs_main; key derivation + the disk reads happen here on the main thread, and the
// pool workers receive only value-type work items (no cs_main, no chainstate pointers).
if (nRandomXVerifyThreads > 0 && rxCheckQueue.IsIdle()) {
std::map<std::string, std::vector<CRandomXCheck> > rxGroups; // grouped by RandomX key
BOOST_FOREACH(CBlockIndex *pidx, vpindexToConnect) {
if (pidx->fRandomXVerified || !RandomXValidationRequired(pidx->GetHeight()))
continue;
std::string rxKey = GetRandomXKey(pidx->GetHeight());
if (rxKey.empty())
continue; // can't derive key -> inline fallback
CBlock blk;
if (!ReadBlockFromDisk(blk, pidx, false))
continue; // -> inline fallback
if (blk.nSolution.size() != 32) // RANDOMX_HASH_SIZE; wrong size -> inline (will error)
continue;
rxGroups[rxKey].push_back(CRandomXCheck(rxKey, GetRandomXInput(blk), blk.nSolution.data(), &pidx->fRandomXVerified));
}
for (std::map<std::string, std::vector<CRandomXCheck> >::iterator it = rxGroups.begin(); it != rxGroups.end(); ++it) {
if (!RandomXValidatorPrepareKey(it->first))
break; // cache alloc failed -> leave the rest for the inline fallback
CCheckQueueControl<CRandomXCheck> control(&rxCheckQueue);
control.Add(it->second);
control.Wait();
}
}
// Connect new blocks.
BOOST_REVERSE_FOREACH(CBlockIndex *pindexConnect, vpindexToConnect) {
if (!ConnectTip(state, pindexConnect, pindexConnect == pindexMostWork ? pblock : NULL)) {
@@ -4993,7 +5075,11 @@ bool CheckBlockHeader(int32_t *futureblockp,int32_t height,CBlockIndex *pindex,
{
if ( !CheckEquihashSolution(&blockhdr, Params()) )
return state.DoS(100, error("CheckBlockHeader(): Equihash solution invalid"),REJECT_INVALID, "invalid-solution");
if ( !CheckRandomXSolution(&blockhdr, height) )
// Skip the inline RandomX recompute only if the parallel pre-verify pool already verified
// THIS block (fRandomXVerified set 1:1 on a real hash match). Every other case — pool miss,
// straggler, disabled pool, or any pindex==NULL caller (TestBlockValidity/VerifyDB/header
// accept) — falls through to the inline check, so consensus is unchanged.
if ( !(pindex && pindex->fRandomXVerified) && !CheckRandomXSolution(&blockhdr, height) )
return state.DoS(100, error("CheckBlockHeader(): RandomX solution invalid"),REJECT_INVALID, "invalid-randomx-solution");
}
// Check proof of work matches claimed amount
@@ -5957,6 +6043,15 @@ bool static LoadBlockIndexDB()
pblocktree->ReadReindexing(fReindexing);
fReindex |= fReindexing;
// Restore the loaded-UTXO-snapshot height so the reorg-below-H guard survives restarts.
{
int snapHeight = -1;
if (pblocktree->ReadAssumeutxoHeight(snapHeight) && snapHeight >= 0) {
nAssumeutxoSnapshotHeight = snapHeight;
LogPrintf("%s: loaded-from-UTXO-snapshot height is %d; reorgs below it are refused\n", __func__, snapHeight);
}
}
// Check whether we have a transaction index
pblocktree->ReadFlag("txindex", fTxIndex);
LogPrintf("%s: transaction index %s\n", __func__, fTxIndex ? "enabled" : "disabled");
@@ -6746,6 +6841,13 @@ void static ProcessGetData(CNode* pfrom)
std::deque<CInv>::iterator it = pfrom->vRecvGetData.begin();
vector<CInv> vNotFound;
// Serve up to this many blocks per ProcessGetData pass. The old code broke after a SINGLE block,
// so a 16-block getdata was dribbled out one block per message-handler tick (~100ms), throttling
// block download for every peer fetching from us. Bound the per-pass work (cs_main is held while
// reading blocks from disk); any remainder is served on the next pass (the message handler keeps
// fSleep=false while vRecvGetData is non-empty, so there is no 100ms park between passes).
const unsigned int nMaxBlocksServedPerPass = 16;
unsigned int nBlocksServed = 0;
LOCK(cs_main);
@@ -6863,7 +6965,10 @@ void static ProcessGetData(CNode* pfrom)
}
}
if (inv.type == MSG_BLOCK || inv.type == MSG_FILTERED_BLOCK)
// Serve a bounded batch of blocks per pass rather than one (see nMaxBlocksServedPerPass
// above). The send-buffer gate at the top of the loop still pauses us if the buffer fills;
// this counter bounds the cs_main hold for a (possibly malicious) large getdata.
if ((inv.type == MSG_BLOCK || inv.type == MSG_FILTERED_BLOCK) && ++nBlocksServed >= nMaxBlocksServedPerPass)
break;
}
}
@@ -8145,12 +8250,35 @@ bool SendMessages(CNode* pto, bool fSendTrickle)
if (!pto->fDisconnect && !pto->fClient && (fFetch || !IsInitialBlockDownload()) && state.nBlocksInFlight < MAX_BLOCKS_IN_TRANSIT_PER_PEER) {
vector<CBlockIndex*> vToDownload;
NodeId staller = -1;
FindNextBlocksToDownload(pto->GetId(), MAX_BLOCKS_IN_TRANSIT_PER_PEER - state.nBlocksInFlight, vToDownload, staller);
CBlockIndex *pFrontierStuck = NULL;
FindNextBlocksToDownload(pto->GetId(), MAX_BLOCKS_IN_TRANSIT_PER_PEER - state.nBlocksInFlight, vToDownload, staller, &pFrontierStuck);
BOOST_FOREACH(CBlockIndex *pindex, vToDownload) {
vGetData.push_back(CInv(MSG_BLOCK, pindex->GetBlockHash()));
MarkBlockAsInFlight(pto->GetId(), pindex->GetBlockHash(), consensusParams, pindex);
LogPrint("net", "Requesting block %s (%d) peer=%d\n", pindex->GetBlockHash().ToString(), pindex->GetHeight(), pto->id);
}
// Frontier reassignment: when this peer has nothing new to fetch because the next-needed
// (frontier) block is in flight from another, slow peer and has been stuck beyond a short
// threshold, re-request it from THIS (responsive) peer instead of waiting out the long
// (~72s) timeout or disconnecting the slow peer. This breaks the head-of-line stall that
// throttles IBD when downloading from few, distant peers. Trustless: the block is still
// fully validated on arrival - we only change which peer serves it. -blockreassigntimeout
// = seconds (0 disables; default 5).
static const int64_t nReassignUs = GetArg("-blockreassigntimeout", 5) * 1000000LL;
if (nReassignUs > 0 && vToDownload.empty() && pFrontierStuck != NULL &&
staller != -1 && staller != pto->GetId()) {
map<uint256, pair<NodeId, list<QueuedBlock>::iterator> >::iterator itF =
mapBlocksInFlight.find(pFrontierStuck->GetBlockHash());
if (itF != mapBlocksInFlight.end() && itF->second.first == staller &&
itF->second.second->nTime < nNow - nReassignUs) {
uint256 hReassign = pFrontierStuck->GetBlockHash();
LogPrint("net", "Reassigning stalled frontier block %s (%d) from peer=%d to peer=%d\n",
hReassign.ToString(), pFrontierStuck->GetHeight(), staller, pto->id);
MarkBlockAsReceived(hReassign); // free from slow peer (no disconnect)
vGetData.push_back(CInv(MSG_BLOCK, hReassign));
MarkBlockAsInFlight(pto->GetId(), hReassign, consensusParams, pFrontierStuck); // re-request from this peer
}
}
if (state.nBlocksInFlight == 0 && staller != -1) {
if (State(staller)->nStallingSince == 0) {
State(staller)->nStallingSince = nNow;