Compare commits

...

5 Commits

Author SHA1 Message Date
Pratik Mankawde
a56c3b452c Merge branch 'develop'
Resolve conflicts with develop's typedef/using rename (#8177) in the
sweepPartition declarations and definitions.
2026-10-02 16:34:17 +01:00
Pratik Mankawde
0f25fe012d Merge branch 'develop' into pratik/partitioned-map-two-partitions 2026-09-21 13:34:29 +01:00
Pratik Mankawde
5429595fb9 perf: Raise the sweep worker cap to 8
A cap of 4 measured slower than the uncapped sweep on 8-core hosts, so
raise the cap to 8 and take the question of the right value to review.

Trim the sweep comments to the fact and its consequence.
2026-09-21 13:32:47 +01:00
Pratik Mankawde
f5900b96e8 perf: Cap the number of threads TaggedCache::sweep() starts
sweep() started one thread per cache partition, and the partition count
defaults to the host's core count, so a sweep of one cache started one
thread per core. Several caches are swept per timer tick, so the churn
multiplied by the number of caches.

Cap the workers instead of the partitions. sweep() now starts
min(partitions, kMaxSweepThreads) of them, and worker w takes partitions
w, w + workerCount, w + 2 * workerCount and so on, which covers every
partition exactly once.

sweepHelper decided what one partition's sweep does and also wrapped it
in a std::thread. Split those: sweepPartition returns void and runs in
the calling thread, and sweep() alone owns thread creation. Both
overloads change the same way; the sweeping logic is untouched.

Fewer workers means the exclusive cache lock is held for longer, since
sweep() joins inside it. That trade is unmeasured.

Add gtest cases for the cap, and for eviction reaching every partition on
both the key/value and key-only caches.
2026-09-17 22:04:59 +01:00
Pratik Mankawde
07fa00ef39 perf: Use a fixed partition count in PartitionedUnorderedMap
The default partition count was the host's core count, and
TaggedCache::sweep() starts one thread per partition, so a sweep created
as many threads as the host has cores. Fix the default at 2, exposed as
the kDefaultPartitions constant.

Both arms of the constructor's initializer are now non-zero, so the
non-zero assertion can no longer fire and is removed. The header no
longer needs <thread> or instrumentation.h.

Add a GTest suite covering the default, the zero fallback, an explicit
count, and the key split across partitions.
2026-09-17 19:51:19 +01:00
3 changed files with 188 additions and 89 deletions

View File

@@ -19,7 +19,6 @@
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <type_traits>
#include <vector>
@@ -73,6 +72,14 @@ public:
using SharedWeakComboPointerType = SharedWeakUnionPointerType;
using SharedPointerType = SharedPointer;
/**
* Most worker threads a single sweep() may start.
*
* A worker takes several partitions when there are more than this, so the
* thread count does not follow the host's core count.
*/
static constexpr std::size_t kMaxSweepThreads = 8;
public:
TaggedCache(
std::string const& name,
@@ -357,8 +364,18 @@ private:
using CacheType = HardenedPartitionedHashMap<key_type, Entry, Hash, KeyEqual>;
[[nodiscard]] std::thread
sweepHelper(
/**
* Sweeps one partition of a key/value cache, in the calling thread.
*
* @param whenExpire Entries last accessed at or before this point expire.
* @param now Current time, used to pull back a future timestamp.
* @param partition The one partition to walk.
* @param stuffToSweep Collects evicted pointers, destroyed once the caller
* releases the cache lock.
* @param allRemovals Accumulates removals across all workers.
*/
void
sweepPartition(
ClockType::time_point const& whenExpire,
[[maybe_unused]] ClockType::time_point const& now,
KeyValueCacheType::MapType& partition,
@@ -366,8 +383,18 @@ private:
std::atomic<int>& allRemovals,
std::scoped_lock<std::recursive_mutex> const&);
[[nodiscard]] std::thread
sweepHelper(
/**
* Sweeps one partition of a key-only cache, in the calling thread.
*
* A key-only cache owns no pointers, so stuffToSweep is unused.
*
* @param whenExpire Entries last accessed at or before this point expire.
* @param now Current time, used to pull back a future timestamp.
* @param partition The one partition to walk.
* @param allRemovals Accumulates removals across all workers.
*/
void
sweepPartition(
ClockType::time_point const& whenExpire,
ClockType::time_point const& now,
KeyOnlyCacheType::MapType& partition,

View File

@@ -6,6 +6,7 @@
#include <xrpl/basics/scope.h>
#include <algorithm>
#include <thread>
namespace xrpl {
@@ -261,14 +262,23 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
<< " aging at " << (now - whenExpire).count() << " of " << targetAge_.count();
}
// Worker w takes partitions w, w + workerCount, w + 2 * workerCount
// and so on, covering every partition exactly once.
std::size_t const workerCount = std::min(cache_.partitions(), kMaxSweepThreads);
std::vector<std::thread> workers;
workers.reserve(cache_.partitions());
workers.reserve(workerCount);
std::atomic<int> allRemovals = 0;
for (std::size_t p = 0; p < cache_.partitions(); ++p)
for (std::size_t w = 0; w < workerCount; ++w)
{
workers.push_back(sweepHelper(
whenExpire, now, cache_.map()[p], allStuffToSweep[p], allRemovals, lock));
workers.emplace_back([&, this, w]() {
for (std::size_t p = w; p < cache_.partitions(); p += workerCount)
{
sweepPartition(
whenExpire, now, cache_.map()[p], allStuffToSweep[p], allRemovals, lock);
}
});
}
for (std::thread& worker : workers)
worker.join();
@@ -773,9 +783,9 @@ template <
class Hash,
class KeyEqual,
class Mutex>
inline std::thread
inline void
TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash, KeyEqual, Mutex>::
sweepHelper(
sweepPartition(
ClockType::time_point const& whenExpire,
[[maybe_unused]] ClockType::time_point const& now,
KeyValueCacheType::MapType& partition,
@@ -783,65 +793,63 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
std::atomic<int>& allRemovals,
std::scoped_lock<std::recursive_mutex> const&)
{
return std::thread([&, this]() {
int cacheRemovals = 0;
int mapRemovals = 0;
int cacheRemovals = 0;
int mapRemovals = 0;
// Keep references to all the stuff we sweep
// so that we can destroy them outside the lock.
stuffToSweep.reserve(partition.size());
// Keep references to all the stuff we sweep
// so that we can destroy them outside the lock.
stuffToSweep.reserve(partition.size());
{
auto cit = partition.begin();
while (cit != partition.end())
{
auto cit = partition.begin();
while (cit != partition.end())
if (cit->second.isWeak())
{
if (cit->second.isWeak())
// weak
if (cit->second.isExpired())
{
// weak
if (cit->second.isExpired())
{
stuffToSweep.emplace_back(std::move(cit->second.ptr));
++mapRemovals;
cit = partition.erase(cit);
}
else
{
++cit;
}
}
else if (cit->second.lastAccess <= whenExpire)
{
// strong, expired
++cacheRemovals;
if (cit->second.ptr.useCount() == 1)
{
stuffToSweep.emplace_back(std::move(cit->second.ptr));
++mapRemovals;
cit = partition.erase(cit);
}
else
{
// remains weakly cached
cit->second.ptr.convertToWeak();
++cit;
}
stuffToSweep.emplace_back(std::move(cit->second.ptr));
++mapRemovals;
cit = partition.erase(cit);
}
else
{
// strong, not expired
++cit;
}
}
else if (cit->second.lastAccess <= whenExpire)
{
// strong, expired
++cacheRemovals;
if (cit->second.ptr.useCount() == 1)
{
stuffToSweep.emplace_back(std::move(cit->second.ptr));
++mapRemovals;
cit = partition.erase(cit);
}
else
{
// remains weakly cached
cit->second.ptr.convertToWeak();
++cit;
}
}
else
{
// strong, not expired
++cit;
}
}
}
if (mapRemovals || cacheRemovals)
{
JLOG(journal_.debug())
<< "TaggedCache partition sweep " << name_ << ": cache = " << partition.size()
<< "-" << cacheRemovals << ", map-=" << mapRemovals;
}
if (mapRemovals > 0 || cacheRemovals > 0)
{
JLOG(journal_.debug()) << "TaggedCache partition sweep " << name_
<< ": cache = " << partition.size() << "-" << cacheRemovals
<< ", map-=" << mapRemovals;
}
allRemovals += cacheRemovals;
});
allRemovals += cacheRemovals;
}
template <
@@ -853,9 +861,9 @@ template <
class Hash,
class KeyEqual,
class Mutex>
inline std::thread
inline void
TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash, KeyEqual, Mutex>::
sweepHelper(
sweepPartition(
ClockType::time_point const& whenExpire,
ClockType::time_point const& now,
KeyOnlyCacheType::MapType& partition,
@@ -863,43 +871,41 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
std::atomic<int>& allRemovals,
std::scoped_lock<std::recursive_mutex> const&)
{
return std::thread([&, this]() {
// NOLINTBEGIN https://github.com/XRPLF/rippled/issues/7056
int cacheRemovals = 0;
int mapRemovals = 0;
// NOLINTEND
// NOLINTBEGIN https://github.com/XRPLF/rippled/issues/7056
int cacheRemovals = 0;
int mapRemovals = 0;
// NOLINTEND
// Keep references to all the stuff we sweep
// so that we can destroy them outside the lock.
// Keep references to all the stuff we sweep
// so that we can destroy them outside the lock.
{
auto cit = partition.begin();
while (cit != partition.end())
{
auto cit = partition.begin();
while (cit != partition.end())
if (cit->second.lastAccess > now)
{
if (cit->second.lastAccess > now)
{
cit->second.lastAccess = now;
++cit;
}
else if (cit->second.lastAccess <= whenExpire)
{
cit = partition.erase(cit);
}
else
{
++cit;
}
cit->second.lastAccess = now;
++cit;
}
else if (cit->second.lastAccess <= whenExpire)
{
cit = partition.erase(cit);
}
else
{
++cit;
}
}
}
if (mapRemovals > 0 || cacheRemovals > 0)
{
JLOG(journal_.debug())
<< "TaggedCache partition sweep " << name_ << ": cache = " << partition.size()
<< "-" << cacheRemovals << ", map-=" << mapRemovals;
}
if (mapRemovals > 0 || cacheRemovals > 0)
{
JLOG(journal_.debug()) << "TaggedCache partition sweep " << name_
<< ": cache = " << partition.size() << "-" << cacheRemovals
<< ", map-=" << mapRemovals;
}
allRemovals += cacheRemovals;
});
allRemovals += cacheRemovals;
}
} // namespace xrpl

View File

@@ -10,6 +10,7 @@
#include <gtest/gtest.h>
#include <helpers/TestSink.h>
#include <cstddef>
#include <memory>
#include <string>
#include <utility>
@@ -243,4 +244,69 @@ TEST(TaggedCacheTest, tagged_cache)
}
}
TEST(TaggedCacheTest, sweep_thread_count_is_capped)
{
using Cache = TaggedCache<LedgerIndex, std::string>;
// Without the cap, sweep() runs one worker per partition, and the
// partition count follows the host's core count.
EXPECT_EQ(Cache::kMaxSweepThreads, 8u);
}
TEST(TaggedCacheTest, sweep_evicts_from_every_partition)
{
using namespace std::chrono_literals;
beast::Journal const journal{TestSink::instance()};
TestStopwatch clock;
clock.set(0);
using Cache = TaggedCache<LedgerIndex, std::string>;
// targetSize 0 keeps whenExpire at now - targetAge_, so every entry below
// expires on the first tick rather than being aged proportionally.
Cache c("sweep-all-partitions", 0, 1s, clock, journal);
// More keys than any plausible partition count, so each partition holds
// several. A worker that skipped a partition would leave its keys behind.
std::size_t const count = 512;
for (std::size_t key = 0; key < count; ++key)
EXPECT_FALSE(c.insert(static_cast<LedgerIndex>(key), std::to_string(key)));
ASSERT_EQ(c.size(), count);
ASSERT_EQ(c.getCacheSize(), static_cast<int>(count));
++clock;
c.sweep();
EXPECT_EQ(c.size(), 0);
EXPECT_EQ(c.getCacheSize(), 0);
}
TEST(TaggedCacheTest, key_only_sweep_evicts_from_every_partition)
{
using namespace std::chrono_literals;
beast::Journal const journal{TestSink::instance()};
TestStopwatch clock;
clock.set(0);
// The key-only cache is a separate sweepPartition overload, so it needs
// its own coverage.
using Cache = TaggedCache<LedgerIndex, int, true>;
Cache c("key-only-sweep-all-partitions", 0, 1s, clock, journal);
std::size_t const count = 512;
for (std::size_t key = 0; key < count; ++key)
EXPECT_TRUE(c.insert(static_cast<LedgerIndex>(key)));
ASSERT_EQ(c.size(), count);
++clock;
c.sweep();
EXPECT_EQ(c.size(), 0);
}
} // namespace xrpl