mirror of
https://github.com/bitcoin/bitcoin.git
synced 2026-09-11 21:20:39 +02:00
fees: add caching to MemPoolFeeRateEstimator
Cache previous mempool fee rate estimates. Cached estimates are tagged with the chain tip they were computed on (the template's hashPrevBlock). They are only served while they are not stale and the chain tip has not changed. This avoids generating block templates too often. The estimator lock is not held while building a block template, so concurrent callers may duplicate estimation work; the tip tag keeps stale results out of the cache. Co-authored-by: willcl-ark <will@256k1.dev>
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
#include <validation.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
MemPoolFeeRateEstimator::Percentiles MemPoolFeeRateEstimator::CalculateMaxWeightPercentiles(std::span<const FeePerVSize> chunk_feerates)
|
||||
{
|
||||
@@ -39,6 +40,25 @@ MemPoolFeeRateEstimator::Percentiles MemPoolFeeRateEstimator::CalculateMaxWeight
|
||||
return percentiles;
|
||||
}
|
||||
|
||||
bool MemPoolFeeRateEstimatorCache::IsStale() const
|
||||
{
|
||||
return !m_fee_rate_estimation || (m_last_updated + CACHE_LIFE) < NodeClock::now();
|
||||
}
|
||||
|
||||
std::optional<MemPoolFeeRateEstimatorCache::FeeRateEstimate>
|
||||
MemPoolFeeRateEstimatorCache::GetCachedEstimate(const uint256& tip_hash) const
|
||||
{
|
||||
if (IsStale() || tip_hash != m_tip_hash) return std::nullopt;
|
||||
return m_fee_rate_estimation;
|
||||
}
|
||||
|
||||
void MemPoolFeeRateEstimatorCache::Update(FeePerVSize conservative, FeePerVSize economical, const uint256& tip_hash)
|
||||
{
|
||||
m_fee_rate_estimation = {conservative, economical};
|
||||
m_tip_hash = tip_hash;
|
||||
m_last_updated = NodeClock::now();
|
||||
}
|
||||
|
||||
//! Build the error result for a failed mempool fee rate estimation.
|
||||
static util::Unexpected<FeeRateEstimationError> EstimationError(std::string error)
|
||||
{
|
||||
@@ -51,6 +71,25 @@ util::Expected<FeeRateEstimation, FeeRateEstimationError> MemPoolFeeRateEstimato
|
||||
if (!m_mempool.GetLoadTried()) {
|
||||
return EstimationError(strprintf("%s: Mempool not loaded yet, no fee rate estimate available", FeeRateEstimatorTypeToString(estimator_type)));
|
||||
}
|
||||
// The estimator lock is not held while building a block template, so
|
||||
// in a rare edge case concurrent callers may duplicate work.
|
||||
//
|
||||
// Cached fee rate estimates are tagged with the chain tip they were computed on
|
||||
// and only served from the cache while that tip is current.
|
||||
//
|
||||
// The fee rate estimate returned directly below may still reflect a tip that went
|
||||
// stale during the call; that is an accepted tradeoff of not holding
|
||||
// locks across block assembly.
|
||||
{
|
||||
const uint256 tip_hash{WITH_LOCK(::cs_main, return Assume(m_chainman.CurrentChainstate().m_chain.Tip())->GetBlockHash())};
|
||||
LOCK(cs);
|
||||
const auto cached_estimate = m_cache.GetCachedEstimate(tip_hash);
|
||||
if (cached_estimate) {
|
||||
const auto cached_feerate{
|
||||
conservative ? cached_estimate->m_conservative : cached_estimate->m_economical};
|
||||
return FeeRateEstimation{estimator_type, cached_feerate, MEMPOOL_FEE_ESTIMATOR_MAX_TARGET};
|
||||
}
|
||||
}
|
||||
node::BlockCreateOptions options;
|
||||
options.test_block_validity = false;
|
||||
const auto blocktemplate = WITH_LOCK(::cs_main, return (node::BlockAssembler{m_chainman.CurrentChainstate(), &m_mempool, options}).CreateNewBlock());
|
||||
@@ -63,6 +102,7 @@ util::Expected<FeeRateEstimation, FeeRateEstimationError> MemPoolFeeRateEstimato
|
||||
const FeePerVSize floor{std::max(m_mempool.m_opts.min_relay_feerate, m_mempool.GetMinFee()).GetFeePerVSize()};
|
||||
const FeePerVSize p50{percentiles.p50.IsEmpty() ? floor : percentiles.p50};
|
||||
const FeePerVSize p75{percentiles.p75.IsEmpty() ? floor : percentiles.p75};
|
||||
WITH_LOCK(cs, m_cache.Update(p50, p75, blocktemplate->block.hashPrevBlock));
|
||||
LogDebug(BCLog::ESTIMATEFEE, "%s: conservative/economical fee rate: %s/%s %s/kvB",
|
||||
FeeRateEstimatorTypeToString(estimator_type), CFeeRate(p50).GetFeePerK(),
|
||||
CFeeRate(p75).GetFeePerK(), CURRENCY_ATOM);
|
||||
|
||||
@@ -5,10 +5,16 @@
|
||||
#ifndef BITCOIN_POLICY_FEES_MEMPOOL_ESTIMATOR_H
|
||||
#define BITCOIN_POLICY_FEES_MEMPOOL_ESTIMATOR_H
|
||||
|
||||
#include <sync.h>
|
||||
#include <threadsafety.h>
|
||||
#include <uint256.h>
|
||||
#include <util/expected.h>
|
||||
#include <util/feefrac.h>
|
||||
#include <util/fees.h>
|
||||
#include <util/time.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
|
||||
class ChainstateManager;
|
||||
@@ -17,6 +23,35 @@ class CTxMemPool;
|
||||
// Fee rate estimate for confirmation target above this is not reliable,
|
||||
// as mempool conditions are likely to change.
|
||||
constexpr int MEMPOOL_FEE_ESTIMATOR_MAX_TARGET{2};
|
||||
constexpr std::chrono::seconds CACHE_LIFE{7};
|
||||
|
||||
/**
|
||||
* MemPoolFeeRateEstimatorCache holds a cache of recent fee rate estimates.
|
||||
* A cached fee rate is only provided while it is not older than CACHE_LIFE
|
||||
* and the chain tip has not changed.
|
||||
*/
|
||||
class MemPoolFeeRateEstimatorCache
|
||||
{
|
||||
public:
|
||||
MemPoolFeeRateEstimatorCache() = default;
|
||||
MemPoolFeeRateEstimatorCache(const MemPoolFeeRateEstimatorCache&) = delete;
|
||||
MemPoolFeeRateEstimatorCache& operator=(const MemPoolFeeRateEstimatorCache&) = delete;
|
||||
/** Returns true if the cache is empty or older than CACHE_LIFE. */
|
||||
bool IsStale() const;
|
||||
struct FeeRateEstimate {
|
||||
FeePerVSize m_conservative;
|
||||
FeePerVSize m_economical;
|
||||
};
|
||||
/** Returns cached estimates if not stale and computed on tip_hash, nullopt otherwise. */
|
||||
std::optional<FeeRateEstimate> GetCachedEstimate(const uint256& tip_hash) const;
|
||||
/** Update the cache with new estimates computed on tip_hash. */
|
||||
void Update(FeePerVSize conservative, FeePerVSize economical, const uint256& tip_hash);
|
||||
|
||||
private:
|
||||
std::optional<FeeRateEstimate> m_fee_rate_estimation;
|
||||
uint256 m_tip_hash;
|
||||
NodeClock::time_point m_last_updated{};
|
||||
};
|
||||
|
||||
/**
|
||||
* Estimate the fee rate required for a transaction to be included in the next block.
|
||||
@@ -44,7 +79,8 @@ public:
|
||||
* @param[in] chunk_feerates Block template chunk fee rates sorted by descending mining score.
|
||||
*/
|
||||
static Percentiles CalculateMaxWeightPercentiles(std::span<const FeePerVSize> chunk_feerates);
|
||||
util::Expected<FeeRateEstimation, FeeRateEstimationError> EstimateFeeRate(bool conservative) const;
|
||||
util::Expected<FeeRateEstimation, FeeRateEstimationError> EstimateFeeRate(bool conservative) const
|
||||
EXCLUSIVE_LOCKS_REQUIRED(!cs);
|
||||
unsigned int MaximumTarget() const
|
||||
{
|
||||
return MEMPOOL_FEE_ESTIMATOR_MAX_TARGET;
|
||||
@@ -53,6 +89,8 @@ public:
|
||||
private:
|
||||
const CTxMemPool& m_mempool;
|
||||
ChainstateManager& m_chainman;
|
||||
mutable Mutex cs;
|
||||
mutable MemPoolFeeRateEstimatorCache m_cache GUARDED_BY(cs);
|
||||
};
|
||||
|
||||
#endif // BITCOIN_POLICY_FEES_MEMPOOL_ESTIMATOR_H
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <uint256.h>
|
||||
#include <util/feefrac.h>
|
||||
#include <util/fees.h>
|
||||
#include <util/time.h>
|
||||
#include <validation.h>
|
||||
|
||||
#include <boost/test/unit_test.hpp>
|
||||
@@ -73,6 +74,31 @@ BOOST_AUTO_TEST_CASE(calculate_max_weight_percentiles)
|
||||
BOOST_CHECK(ByRatio{percentiles.p50} > ByRatio{percentiles.p75});
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(mempool_fee_rate_estimator_cache)
|
||||
{
|
||||
MemPoolFeeRateEstimatorCache cache;
|
||||
const uint256 tip_hash{uint256::ONE};
|
||||
const uint256 next_tip_hash{uint256{2}};
|
||||
const FeePerVSize conservative{2, 1};
|
||||
const FeePerVSize economical{1, 1};
|
||||
|
||||
BOOST_CHECK(cache.IsStale());
|
||||
BOOST_CHECK(!cache.GetCachedEstimate(tip_hash));
|
||||
|
||||
cache.Update(conservative, economical, tip_hash);
|
||||
BOOST_CHECK(!cache.IsStale());
|
||||
const auto cached{cache.GetCachedEstimate(tip_hash)};
|
||||
BOOST_REQUIRE(cached);
|
||||
BOOST_CHECK(cached->m_conservative == conservative);
|
||||
BOOST_CHECK(cached->m_economical == economical);
|
||||
BOOST_CHECK(!cache.GetCachedEstimate(next_tip_hash));
|
||||
|
||||
SetMockTime(GetTime<std::chrono::seconds>() + CACHE_LIFE + std::chrono::seconds{1});
|
||||
BOOST_CHECK(cache.IsStale());
|
||||
BOOST_CHECK(!cache.GetCachedEstimate(tip_hash));
|
||||
SetMockTime(0);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(MempoolFeeRateEstimator)
|
||||
{
|
||||
auto mempool_estimator = MemPoolFeeRateEstimator(*m_node.mempool, *m_node.chainman);
|
||||
@@ -100,12 +126,18 @@ BOOST_AUTO_TEST_CASE(MempoolFeeRateEstimator)
|
||||
BOOST_CHECK(result->feerate == floor);
|
||||
BOOST_CHECK(result->feerate_estimator == FeeRateEstimatorType::MEMPOOL_POLICY);
|
||||
BOOST_CHECK_EQUAL(result->returned_target, MEMPOOL_FEE_ESTIMATOR_MAX_TARGET);
|
||||
|
||||
// The floor estimate is cached like any other; a second call returns the same value.
|
||||
const auto cached_result = mempool_estimator.EstimateFeeRate(/*conservative=*/true);
|
||||
BOOST_REQUIRE(cached_result.has_value());
|
||||
BOOST_CHECK(cached_result->feerate == floor);
|
||||
}
|
||||
TestMemPoolEntryHelper entry;
|
||||
const auto tx_vsize = entry.FromTx(MakeRandomTx()).GetTxSize();
|
||||
const CAmount low_fee{CENT / 3000};
|
||||
const CAmount med_fee{CENT / 100};
|
||||
const CAmount high_fee{CENT / 10};
|
||||
const CAmount very_high_fee{CENT};
|
||||
// A mempool that cannot fill 50% of a block leaves both percentiles empty,
|
||||
// so both estimate still fall back to the floor.
|
||||
{
|
||||
@@ -116,6 +148,8 @@ BOOST_AUTO_TEST_CASE(MempoolFeeRateEstimator)
|
||||
TryAddToMempool(*m_node.mempool, entry.Fee(high_fee).FromTx(MakeRandomTx()));
|
||||
}
|
||||
}
|
||||
// Expire the cached floor estimate so the denser mempool is observed.
|
||||
SetMockTime(GetTime<std::chrono::seconds>() + CACHE_LIFE + std::chrono::seconds{1});
|
||||
const auto result = mempool_estimator.EstimateFeeRate(/*conservative=*/true);
|
||||
BOOST_REQUIRE(result.has_value());
|
||||
BOOST_CHECK(result->feerate == floor);
|
||||
@@ -130,6 +164,7 @@ BOOST_AUTO_TEST_CASE(MempoolFeeRateEstimator)
|
||||
TryAddToMempool(*m_node.mempool, entry.Fee(med_fee).FromTx(MakeRandomTx()));
|
||||
}
|
||||
}
|
||||
SetMockTime(GetTime<std::chrono::seconds>() + CACHE_LIFE + std::chrono::seconds{1});
|
||||
const auto conservative = mempool_estimator.EstimateFeeRate(/*conservative=*/true);
|
||||
const auto economical = mempool_estimator.EstimateFeeRate(/*conservative=*/false);
|
||||
BOOST_REQUIRE(conservative.has_value());
|
||||
@@ -147,6 +182,8 @@ BOOST_AUTO_TEST_CASE(MempoolFeeRateEstimator)
|
||||
TryAddToMempool(*m_node.mempool, entry.Fee(low_fee).FromTx(MakeRandomTx()));
|
||||
}
|
||||
}
|
||||
// Expire the sparse-result cache before expecting the estimator to observe the denser mempool.
|
||||
SetMockTime(GetTime<std::chrono::seconds>() + CACHE_LIFE + std::chrono::seconds{1});
|
||||
const auto result_conservative = mempool_estimator.EstimateFeeRate(/*conservative=*/true);
|
||||
const auto result_economical = mempool_estimator.EstimateFeeRate(/*conservative=*/false);
|
||||
BOOST_CHECK(result_conservative.has_value());
|
||||
@@ -158,6 +195,22 @@ BOOST_AUTO_TEST_CASE(MempoolFeeRateEstimator)
|
||||
BOOST_CHECK(result_economical->feerate_estimator == FeeRateEstimatorType::MEMPOOL_POLICY);
|
||||
BOOST_CHECK_EQUAL(result_conservative->returned_target, MEMPOOL_FEE_ESTIMATOR_MAX_TARGET);
|
||||
BOOST_CHECK_EQUAL(result_economical->returned_target, MEMPOOL_FEE_ESTIMATOR_MAX_TARGET);
|
||||
|
||||
// Adding another 30% of very-high-fee transactions should change the
|
||||
// estimates after recomputation, but not while the cached estimate is fresh.
|
||||
{
|
||||
LOCK2(cs_main, m_node.mempool->cs);
|
||||
while ((m_node.mempool->GetTotalTxSize() * WITNESS_SCALE_FACTOR) <=
|
||||
(DEFAULT_BLOCK_MAX_WEIGHT * 105 / 100)) {
|
||||
TryAddToMempool(*m_node.mempool, entry.Fee(very_high_fee).FromTx(MakeRandomTx()));
|
||||
}
|
||||
}
|
||||
BOOST_CHECK(mempool_estimator.EstimateFeeRate(/*conservative=*/false).value().feerate == FeeFrac(low_fee, tx_vsize));
|
||||
BOOST_CHECK(mempool_estimator.EstimateFeeRate(/*conservative=*/true).value().feerate == FeeFrac(med_fee, tx_vsize));
|
||||
// Expire the cache by advancing mock time past CACHE_LIFE so the next call recomputes.
|
||||
SetMockTime(GetTime<std::chrono::seconds>() + CACHE_LIFE + std::chrono::seconds{1});
|
||||
BOOST_CHECK(mempool_estimator.EstimateFeeRate(/*conservative=*/false).value().feerate == FeeFrac(med_fee, tx_vsize));
|
||||
BOOST_CHECK(mempool_estimator.EstimateFeeRate(/*conservative=*/true).value().feerate == FeeFrac(high_fee, tx_vsize));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user