-
Type:
Improvement
-
Resolution: Unresolved
-
Priority:
Major - P3
-
None
-
Affects Version/s: None
-
Component/s: Cache and Eviction
-
None
-
Storage Engines - Transactions
-
1,913.729
-
SE Transactions - 2026-05-22, SE Transactions - 2026-06-05, SE Transactions - 2026-06-19, SE Transactions - 2026-07-03, SE Transactions - 2026-07-17, SE Transactions - 2026-07-31, SE Transactions - 2026-08-14
-
5
Motivation
On large caches (18 GB+) under high update pressure, the eviction walker is the candidate-discovery bottleneck: it samples the btree hoping to find the oldest dirty pages before pressure reaches the trigger, and on fast dirty generation it cannot keep up. The WT-17234 investigation demonstrated this across eight tuning attempts — deeper sampling, walker persistence, dominance gating, cache-fill gates — every variant that helped one workload class hurt another. FTDC on the failing approaches showed the walker doing 1.83x the work of baseline while delivering 32% lower insert throughput.
This ticket implements a structurally different approach: drive dirty-candidate discovery from the write path instead of from walker sampling. The walker stops being the throughput-limiting component; it only has to drain entries the producer has already identified.
Design
Producer (modify path, src/evict/evict_dirty_index.c)
Every successful cursor modify announces the dirty leaf ref into a per-btree bounded ring (WTI_DIRTY_INDEX). The ring is a fixed-size circular array where each slot carries the WT_REF pointer plus its own sequence counter, independent of the shared head/tail counters (slot = position \& mask, capacity a power of two).
- Capacity: derived from the btree's on-disk file size divided by an estimated page count (min(maxleafpage, 64KB)), clamped to [WTI_DIRTY_INDEX_MIN_CAPACITY (16K), WTI_DIRTY_INDEX_MAX_CAPACITY (256K)] slots and rounded up to a power of two. The history store always gets the maximum capacity. Allocated once when the btree handle opens (_wt_dirty_index_alloc) and freed at handle close (_wt_dirty_index_destroy); capacity is fixed for the handle's lifetime.
- Dedup: WT_PAGE.dirty_index_slot is a one-indexed back-pointer; a page already in the ring is a single relaxed atomic load + branch, no ring access.
- Slot reservation: a producer CAS-advances head, but only once the target slot's sequence counter confirms the previous occupant has already been drained. A sequence lagging the reserved position means the ring is full (counted as insert_ring_full) — the producer bails rather than overwrite a live entry or block. A sequence ahead of it means another producer just took the position, so the producer retries.
- Slot ownership isn't page ownership: winning the slot CAS still requires winning a second CAS on the page's back-pointer (NONE → this slot), then re-validating that ref/page/state didn't change underneath it. Losing either race abandons the slot (insert_contended) instead of blocking — the ring is best-effort, the tree walker remains the source of truth.
- head and tail are padded onto separate cache lines so the single-consumer drain's tail update can't bounce the line the producers are spinning on.
Consumer (drain)
The dirty-index helpers (_wt_dirty_index_alloc/destroy/insert/clear_page) live in src/evict/evict_dirty_index.c; the drain itself (evict_dirty_index_drain, evict_dirty_index_drain_ring) lives alongside _evict_walk_tree in src/evict/evict_walk.c, since it shares that function's per-tree queue-slot budget and candidacy filter.
- The drain runs before the walker on each per-btree visit, filling the leading portion of the tree's queue-slot budget for this pass; whatever it doesn't fill, the walker's tree traversal covers.
- Each popped ref is protected by a short-lived hazard pointer before ref->page is dereferenced. WT_GEN_SPLIT is held across the pop loop so a concurrent split cannot reclaim a ref still sitting in the ring.
- Every popped candidate passes through the walker's own per-page candidacy filter (__evict_try_queue_page), so drain and walker apply identical rules. A candidate rejected by the filter (e.g. it doesn't qualify under the current pressure mode) is re-inserted at the back of the ring rather than dropped (drain_reinserted), so the drain is no longer a net-extractive pass relative to the walker — this replaced an earlier design that lost such candidates outright.
- Checkpoint interaction: a btree being checkpointed, or a disaggregated btree already visited by the running checkpoint, cannot have dirty pages evicted right now; the drain is skipped for that pass (drain_skipped_checkpoint / drain_skipped_disagg_checkpointed). Under a precise checkpoint, pages whose newest commit timestamp is ahead of the pinned stable timestamp are also not evictable; the drain tracks the timestamp range of pages rejected for this reason and parks itself (drain_skipped_stable_lag) until the pinned stable timestamp crosses the midpoint of that range, instead of repeatedly re-paying the candidacy filter on pages it cannot yet queue.
Lifetime safety
All ref-free paths (_wti_free_ref, split_parent_discard_ref) and the evict-page path (wt_evict) clear the ring entry before the WT_REF or WT_PAGE is reclaimed, via wt_dirty_index_clear_page. Temporary refs (e.g. the scratch ref in _wt_split_rewrite) never enter the ring because the producer only accepts WT_REF_FLAG_LEAF refs. The page-side back-pointer gives O(1) invalidation.
Adaptive drain scheduling (per btree)
- Steady mode: the drain runs on every pass now, not alternating with the walker — alternation was the original design but is unnecessary once filtered candidates are re-inserted instead of lost.
- Park / probe: after WTI_DRAIN_EMPTY_THRESHOLD (8) consecutive empty drains, the per-btree drain is disabled (drain_disabled) so the walker runs every pass at full rate. A disabled drain is still probed once every WTI_DRAIN_PROBE_INTERVAL (32) passes to detect a shift back to write-heavy.
- Clean-pressure yield: once the cache is more than halfway to the eviction trigger (WT_EVICT_CACHE_NOKEEP), the drain is skipped (drain_skipped_clean_pressure) — pre-queuing dirty candidates would compete with the walker for the queue slots it needs to find clean pages, which dominate at that point. drain_consecutive_empty is left untouched during this skip so the drain resumes immediately once pressure eases.
- Internal-page starvation guard: the ring is leaf-only, so only the walker ever queues internal pages. If the drain fills the whole per-tree budget for WTI_DRAIN_PROBE_INTERVAL (32) consecutive passes while the whole cache is over its clean target (WT_EVICT_CACHE_CLEAN), the drain is skipped for one pass so the walker can reclaim internal pages (drain_filled_skips counter, eviction_walk_skipped_drain_filled stat). This fixes an internal-page accumulation regression found on leaf-heavy workloads (upsert/ecommerce) where the drain filled the budget on ~96% of passes.
- The drain checks the runtime-toggleable eviction_dirty_index flag on every entry, so the feature can be disabled without a restart.
Configuration
- eviction_dirty_index (default true): connection-level switch controlling per-btree ring allocation at btree open and drain participation. Rings are allocated when a handle opens and retained until it closes, so flipping this setting only affects handles opened after the change; disabling stops producers and drains immediately but does not free existing rings.
- eviction_dirty_index_disagg (default false): a second gate specifically for disaggregated-storage btrees, checked in both the producer (_wt_dirty_index_alloc, _wt_dirty_index_insert) and the drain. Off by default because ring churn on a disagg btree competes with checkpoint materialization lag; requires eviction_dirty_index to also be true.
Observability (FTDC)
Connection-level (no_clear, no_scale):
| Stat | Description |
|---|---|
| eviction_dirty_index_ring_full_capacity_max | dirty index largest capacity among rings that hit full |
| eviction_dirty_index_ring_peak_occupancy | dirty index largest ring occupancy seen in slots |
Per data-source (CacheStat):
| Stat | Description |
|---|---|
| cache_eviction_dirty_index_insert | dirty index inserts |
| cache_eviction_dirty_index_insert_contended | dirty index inserts abandoned after a page back-pointer race |
| cache_eviction_dirty_index_insert_ring_full | dirty index inserts dropped due to full ring |
| cache_eviction_dirty_index_drain_scanned | dirty index slots examined by drain |
| cache_eviction_dirty_index_drain_queued | dirty index slots queued to ordinary or urgent eviction by drain |
| cache_eviction_dirty_index_drain_filtered | dirty index slots skipped by drain due to candidacy filter |
| cache_eviction_dirty_index_drain_reinserted | dirty index slots reinserted by drain after candidacy filter miss |
| cache_eviction_dirty_index_drain_stale | dirty index slots dropped by drain because the page was clean or already queued |
| cache_eviction_dirty_index_drain_hazard | dirty index drain attempts blocked by hazard-pointer failure |
| cache_eviction_dirty_index_drain_skipped_checkpoint | dirty index drain skipped due to active checkpoint |
| cache_eviction_dirty_index_drain_skipped_disagg_checkpointed | dirty index drain skipped because the disaggregated btree was already visited by the running checkpoint |
| cache_eviction_dirty_index_drain_skipped_stable_lag | dirty index drain passes skipped because the pinned stable timestamp lags the midpoint of the blocked commit timestamp range |
| cache_eviction_dirty_index_drain_skipped_clean_pressure | dirty index drain skipped due to clean-page eviction pressure |
| cache_eviction_dirty_index_trim_reinserted | dirty index slots reinserted after the LRU sort trimmed them |
Plus the shared eviction stat eviction_walk_skipped_drain_filled (walk passes skipped this pass because the drain already filled the tree's whole budget).
Performance — current branch
Initial sys-perf run vs mainline 0af4a8f2635d
Comparison 6a0513b7949db625a12b3f8f (internal Performance Analyzer link, not shared here as it isn't publicly accessible).
Wins:
| Workload | Metric | Δ vs stable | z |
|---|---|---|---|
| linkbench2 MULTIGET_LINK | avg latency | -7.36% | -4.39 |
| linkbench2 MULTIGET_LINK | 95th latency | -8.50% | -4.09 |
| linkbench2 GET_LINKS_LIST | 95th latency | -9.57% | -3.31 |
| linkbench2 GET_LINKS_LIST | avg latency | -7.50% | -3.11 |
| linkbench2 GET_LINKS_LIST | 50th latency | -9.22% | -2.95 |
| linkbench2 GET_NODE | 50th latency | -8.03% | -2.23 |
| linkbench2 UPDATE_LINK | 95th latency | -7.55% | -2.04 |
| bulk_insert_locust_w1 Aggregated | 95th latency | -26.39% | -4.80 |
| bulk_insert_locust_w1 InsertManyWithIndexes | 50th latency | -30.57% | -2.87 |
| bulk_insert_locust_w1 InsertManyWithIndexes | avg latency | -7.76% | -2.24 |
Regressions:
| Workload | Metric | Δ vs stable | z |
|---|---|---|---|
| bulk_insert_locust_w1 InsertMany | 50th latency | +42.66% | +3.36 |
| bulk_insert_locust_w1 InsertManyWithIndexes | 95th latency | +27.20% | +2.08 |
| bulk_insert_locust InsertMany | 95th latency | +4.02% | +2.05 |
| tpce_locust trade_update | throughput | -0.82% | -2.79 |
| tpce_locust market_watch | throughput | -0.13% | -2.60 |
| tpce_locust Aggregated | throughput | -0.19% | -2.06 |
FTDC root cause for the InsertManyWithIndexes p95 regression
During the load_with_indexes phase:
- Producer rate: 11,628/s — index inserts touch many pages per logical insert.
- Producer drops because the ring was full: 5,173/s (30% of inserts).
- Drain successfully queued to eviction: 4,270/s (37% of inserts).
- App-thread eviction time reduced 75% (12,841s → 3,269s), insert throughput +25%, but cache peak hits 95% (eviction_trigger) vs 78.6% in baseline.
The drain was effective when running, but two design choices throttled it exactly when it was needed most:
- Alternation: drain ran only on odd passes, halving its rate under load.
- !CLEAN_HARD gate: drain was disabled entirely when cache reached 95% — the moment dirty candidates are scarcest from the walker's perspective.
Targeted fix run
Comparison 6a05311aee182d55758e78ef (internal Performance Analyzer link, not shared here as it isn't publicly accessible).
After landing the pressure-aware drain (run every pass under hard pressure, no CLEAN_HARD lockout):
| Test | Metric | Original patch | After fix | Outcome |
|---|---|---|---|---|
| InsertMany | 50th latency | +42.66% (z=3.36) | -8.66% (z=-0.68) | regression fully resolved |
| InsertMany | 95th latency | -26.39% (z=-4.80) | +4.26% (z=0.77) | within noise |
| InsertManyWithIndexes | 95th latency | +27.20% (z=2.08) | +20.81% (z=1.59) | below significance, still elevated |
| InsertManyWithIndexes | 50th latency | -30.57% (z=-2.87) | -19.25% (z=-1.81) | improvement preserved |
| InsertManyWithIndexes | avg latency | -7.76% (z=-2.24) | -5.63% (z=-1.62) | improvement preserved |
| InsertManyWithIndexes | throughput | (not significant) | +6.30% (z=1.60) | trending positive |
Note: the targeted patch was a single-task / single-clone run, so per-metric z-scores are inherently less powered than the original 4-clone multipatch.
Latest sys-perf perf-required run (post-develop-merge)
See the comment history on this ticket for the most recent full perf-required comparison and root-cause notes on the flagged read-latency metric (ruled out as a failed-clone artifact, not a regression).
Open follow-ups
- Residual InsertManyWithIndexes 95th latency at +20% (below significance but directionally elevated). Two possible next steps if the trend persists in repeat runs:
- Route drain candidates to the urgent queue when cache fill is critical (≥92%).
- Lower eviction_dirty_target / eviction_target defaults for write-heavy workloads so cache equilibrium stays below the 95% trigger.
- 3-clone multipatch on ycsb.in_cache.100read and top_ten_queries_locust (regressions seen in earlier v17 runs) to confirm whether they are signal or single-run variance.
- Disagg: eviction_dirty_index_disagg defaults to off pending a dedicated sys-perf comparison on the disagg variant to confirm ring churn doesn't worsen checkpoint materialization lag.
Related
- WT-17234 — eight-approach investigation that established the walker is the bottleneck at scale.
- WT-15538 — umbrella ticket for slow eviction under high update ratio.
- WT-16529 — queue usage / empty-queue investigation (pull-side, complementary).
- WT-16665 — dynamic queue resize (pull-side, complementary).
Branch and code layout
Branch wt-17236-dirty-index, kept rebased on develop.
The dirty-index ring struct (WTI_DIRTY_INDEX), the page back-pointer helpers, and the adaptive drain-scheduling thresholds (WTI_DRAIN_EMPTY_THRESHOLD, WTI_DRAIN_PROBE_INTERVAL, WTI_DIRTY_INDEX_MIN/MAX_CAPACITY) are declared in src/evict/evict_private.h. The ring allocation, teardown, insert, and page-clear logic (_wt_dirty_index_alloc/destroy/insert/clear_page) live in their own source file, src/evict/evict_dirty_index.c. The drain (evict_dirty_index_drain, evict_dirty_index_drain_ring) and its scheduling logic live in src/evict/evict_walk.c alongside _evict_walk_tree, which it shares a queue-slot budget and candidacy filter with. Test coverage in test/suite/test_eviction06.py.
- is duplicated by
-
WT-17235 Push-model dirty-page index for eviction candidate discovery
-
- Closed
-
- is related to
-
WT-15538 Investigate slow eviction behavior when updates ratio is high
-
- Open
-
-
WT-16529 Increase eviction queue usage when the queue is frequently empty
-
- Open
-
-
WT-16665 Investigate eviction queue resize based on efficiency
-
- Open
-
-
WT-17392 Split evict_lru.c into focused source files
-
- Closed
-
- related to
-
WT-17634 SIGSEGV in __wt_checkpoint_tree_reconcile_update reached from eviction-time reconcile
-
- Backlog
-
-
WT-17638 SIGSEGV in __wt_ref_addr_copy when ref->home transient NULL during parent split
-
- Closed
-
-
WT-17789 Identify freeable updates to help eviction
-
- Open
-