Skip to content

perf: Cap the number of threads TaggedCache sweep starts - #8243

Open
pratikmankawde wants to merge 4 commits into
developfrom
pratik/partitioned-map-two-partitions
Open

pratikmankawde wants to merge 4 commits into
developfrom
pratik/partitioned-map-two-partitions

Conversation

@pratikmankawde

@pratikmankawde pratikmankawde commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

High Level Overview of Change

TaggedCache::sweep() starts one std::thread per cache partition, and the partition count defaults to std::thread::hardware_concurrency(). So a sweep of one cache starts one thread per core, and ApplicationImp::doSweep sweeps several caches per timer tick.

This caps the worker count at TaggedCache::kMaxSweepThreads, currently 8, and leaves the partition count alone.

The right value for the cap is the open question — that is what this PR is for review on. A cap of 4 was measured slower than no cap at all (below).

Context of Change

Nothing constructs a TaggedCache with an explicit partition count, so every cache uses the default.

Capping workers rather than partitions matters: the partition count also sets the container's sharding for every user of PartitionedUnorderedMap, and only sweep() turns partitions into threads.

There is a cost in the other direction. sweep() joins its workers inside one std::scoped_lock on the cache mutex, so the lock is held for the whole sweep. Fewer workers means the same entries are visited by fewer threads, so that exclusive hold gets longer. That is why a cap of 4 regressed, and it is the reason to doubt any cap below the core count.

Related work on the same mutex, which a reviewer may consider the better lever: #6048 replaces the cache-wide mutex with per-partition locks (#5874 is its prerequisite), and #8240 bounds the online-delete freshen's hold on it. Earlier attempts at this specific knob did not stick — #5442 ended back at all hardware threads, and #5486 was merged then reverted.

API Impact

  • libxrpl change (any change that may affect libxrpl or dependents of libxrpl)

TaggedCache gains a public kMaxSweepThreads. sweepHelper becomes sweepPartition, returning void instead of std::thread; both are private.

Before / After

  1. sweep() starts min(partitions, kMaxSweepThreads) workers instead of one per partition. Worker w takes partitions w, w + workerCount, w + 2 * workerCount, covering each exactly once with nothing shared between workers.
  2. Both sweepHelper overloads become void sweepPartition(...), running in the calling thread, so sweep() alone owns thread creation. The sweeping logic is unchanged; the diff is large only because removing the lambda re-indents both bodies.
  3. <thread> moves from TaggedCache.h to TaggedCache.ipp, which is what builds the worker vector.
  4. allStuffToSweep is still sized per partition, so swept objects are still destroyed after the lock is released.

Test Plan

Three gtest cases in src/tests/libxrpl/basics/TaggedCache.cpp: the cap's value, full eviction on a key/value cache, and the same on a key-only cache (a separate sweepPartition overload). Full eviction is the guard that matters — a worker that skipped a partition would leave its keys behind.

A perf run at this cap will be a no-op. The perf-iac AWS cluster uses z1d.2xlarge validators, 8 cores each (measured, not assumed). So hardware_concurrency() is 8, partitions are 8, and min(8, 8) is 8 — identical to the uncapped baseline. Testing any cap at or above 8 needs larger instances.

The measured run was therefore at a cap of 4, 8 workers down to 4, against this branch's merge base with no other difference. One run, no spread:

Signal Baseline Capped at 4 Delta
rippled_jobq_timer{job_type="sweep"} 3.860 4.233 +9.7%
sweep_q (dequeue wait) 0.001 0.001 flat
Client latency P99 828.64 ms 1060.05 ms +27.9%
TPS 297.45 296.31 −0.4%

The dequeue wait is flat, so the extra time is inside the sweep rather than in scheduling.

Future Tasks

Measure the sweep's lock-hold duration directly rather than inferring it from client latency; sweep() already logs it.

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.
@pratikmankawde pratikmankawde added the DraftRunCI Normally CI does not run on draft PRs. This opts in. label Sep 17, 2026
@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.78261% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
include/xrpl/basics/TaggedCache.ipp 84.8% 7 Missing ⚠️

📢 Thoughts on this report? Let us know!

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.
@pratikmankawde pratikmankawde changed the title perf: Use a fixed partition count in PartitionedUnorderedMap perf: Cap the number of threads TaggedCache sweep starts Sep 17, 2026
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.
@pratikmankawde

pratikmankawde commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

@vlntb @nbougalis — requesting you both to look at this one.

The open question is the value of the cap, not the mechanism. On an 8 core aws-cluster, a cap of 4 measured ~10% slower on the sweep job than no cap at all, because sweep() joins its workers inside the cache-wide lock, so fewer workers lengthens the hold. It is now set to 8, which is a no-op on our 8-core perf-iac aws hosts, so the value is unresolved rather than validated.

@vlntb, you have been here before and I would value the reasons: RIPD-2602's branch (#5442) ended back at all hardware threads, and #5486 was merged then reverted with no stated cause. If the conclusion was that this knob is the wrong one and the cache-wide mutex is the real lever, I would rather close this and put the effort behind #6048 and #5874.

@pratikmankawde
pratikmankawde marked this pull request as ready for review September 21, 2026 12:34

@xrplf-ai-reviewer xrplf-ai-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diff caps sweep() worker threads at min(partitions, kMaxSweepThreads) and converts sweepHelper (which returned std::thread) into sweepPartition (void, run synchronously). Each worker now strides over multiple partitions (w, w+workerCount, w+2*workerCount, ...), covering every partition exactly once with no overlap, and removal counts are still accumulated into the shared std::atomic allRemovals, so the accounting stays correct under the new striping. The sweepPartition bodies are otherwise a straight re-indent of the prior lambda bodies with no logic changes, and allStuffToSweep is still indexed per-partition so eviction/destruction-outside-lock behavior is preserved. Lambda captures (by reference for whenExpire/now/allStuffToSweep/allRemovals/lock, by value for this/w) stay valid for the whole worker lifetime since sweep() joins all workers before returning. I did not find a correctness or security issue in the changed lines; the open question the author raises (right cap value, lock-hold duration under fewer workers) is a performance/design tradeoff already called out explicitly in the description, not a code defect.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Add coverage that verifies the worker cap and exercises sweeping with fewer workers than partitions.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Low severity

Open (1)
What changed in this PR

This PR caps TaggedCache::sweep() at eight workers while preserving partition coverage and adds eviction tests.

Changes:

  • Implements capped, strided worker scheduling.
  • Refactors sweep helpers to run within worker threads.
  • Adds cap and eviction tests for value and key-only caches.
File Summary
src/​tests/​libxrpl/​basics/​TaggedCache.cpp Adds cap and eviction tests; coverage does not observe actual worker creation.
include/​xrpl/​basics/​TaggedCache.ipp Implements capped scheduling and partition sweeping; reduced-worker execution is not deterministically exercised.
include/​xrpl/​basics/​TaggedCache.h Defines the worker cap and sweep helper declarations.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.


// Without the cap, sweep() runs one worker per partition, and the
// partition count follows the host's core count.
EXPECT_EQ(Cache::kMaxSweepThreads, 8u);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DraftRunCI Normally CI does not run on draft PRs. This opts in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants