Skip to content

Commit 898e100

Browse files
committed
Replace the usage_count clock sweep with a cooling-stage evictor
Replace the 0..5 usage_count buffer-replacement policy with a cooling-stage clock (the LeanStore / 2Q-A1 model): a buffer is simply HOT (recently used) or COOL (an eviction candidate), with "pinned" being the existing refcount. There is no per-buffer access counter. - A demand-loaded page is admitted COOL (probationary), not HOT. A second access via PinBuffer promotes it COOL -> HOT (the rescue). So a page touched once -- a sequential scan -- fills and drains the COOL stage and is evicted from it without ever displacing the HOT working set. Scan resistance is intrinsic to the replacement algorithm, which is what lets a later commit remove the BufferAccessStrategy ring buffers entirely. - The foreground sweep in StrategyGetBuffer() reclaims an already-COOL, unpinned buffer, pinning it with a CAS so a racing PinBuffer always wins. Promotion (COOL -> HOT) and demotion (HOT -> COOL) are single-bit transitions; only the eviction claim is a CAS. - The background writer maintains the supply of COOL victims. As its LRU scan runs ahead of the clock hand it demotes HOT buffers to COOL, so the foreground finds a victim in a single pass rather than having to cool buffers itself. The demotion is demand-driven -- bounded by the predicted allocation for the next cycle -- so it stages just enough COOL buffers without cooling the whole pool, and it is done under the buffer header lock the scan already holds. A single second-chance reference bit (set by PinBuffer, cleared on the bgwriter's first pass over a HOT buffer) spares a recently-accessed buffer one cooling pass, keeping the genuinely hot set out of the COOL stage under scan pressure. BgBufferSync also tracks the reusable-buffer density it directly observes on a shorter smoothing window, so a burst of probationary/scan COOL pages is followed promptly rather than averaged away. Under a bulk-dirtying workload the per-cycle clean-write cap is raised from bgwriter_lru_maxpages to predicted demand so the bgwriter keeps supplying clean victims, rather than the foreground sweep having to flush dirty victims inline; normal workloads, where demand is below the cap, are unaffected. The 4-bit usage_count field is reinterpreted in place: bit 0 is the HOT/COOL state (BUF_COOLSTATE_ONE), bit 1 the reference bit (BUF_REFBIT). The 64-bit buffer-state layout -- refcount, flag and lock offsets and their StaticAsserts -- is unchanged; only the meaning of the field and the instructions that touch it change. A StaticAssert requires the field to be at least 2 bits wide so a future width change cannot push the reference bit into the flag bits. BM_MAX_USAGE_COUNT becomes BUF_COOLSTATE_HOT (1), so the pin fast path saturates at HOT. Local (temp-table) buffers get the same two-state treatment; being single-backend they need no background cooler. Because the reference bit shares the field, the full 4-bit value can be 0..3; readers that mean "the cooling state" must use BUF_STATE_GET_COOLSTATE(), which masks to bit 0 and returns only 0 (COOL) or 1 (HOT), never the raw field. contrib/pg_buffercache is updated accordingly: its usagecount column and the pg_buffercache_summary average report the cooling state (0 = COOL, 1 = HOT), and pg_buffercache_usage_counts() buckets on it. Using the raw 4-bit getter there would index its BM_MAX_USAGE_COUNT+1 = 2-element arrays with values up to 3 and overrun the stack; masking to the cooling bit keeps the index in range. The reference bit is deliberately not exposed as usagecount. Depends on the batched clock sweep from the previous commit.
1 parent 40679bd commit 898e100

5 files changed

Lines changed: 239 additions & 87 deletions

File tree

contrib/pg_buffercache/pg_buffercache_pages.c

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ pg_buffercache_pages(PG_FUNCTION_ARGS)
161161
reldatabase = bufHdr->tag.dbOid;
162162
forknum = BufTagGetForkNum(&bufHdr->tag);
163163
blocknum = bufHdr->tag.blockNum;
164-
usagecount = BUF_STATE_GET_USAGECOUNT(buf_state);
164+
usagecount = BUF_STATE_GET_COOLSTATE(buf_state);
165165
pinning_backends = BUF_STATE_GET_REFCOUNT(buf_state);
166166

167167
if (buf_state & BM_DIRTY)
@@ -605,7 +605,7 @@ pg_buffercache_summary(PG_FUNCTION_ARGS)
605605
if (buf_state & BM_VALID)
606606
{
607607
buffers_used++;
608-
usagecount_total += BUF_STATE_GET_USAGECOUNT(buf_state);
608+
usagecount_total += BUF_STATE_GET_COOLSTATE(buf_state);
609609

610610
if (buf_state & BM_DIRTY)
611611
buffers_dirty++;
@@ -655,7 +655,7 @@ pg_buffercache_usage_counts(PG_FUNCTION_ARGS)
655655

656656
CHECK_FOR_INTERRUPTS();
657657

658-
usage_count = BUF_STATE_GET_USAGECOUNT(buf_state);
658+
usage_count = BUF_STATE_GET_COOLSTATE(buf_state);
659659
usage_counts[usage_count]++;
660660

661661
if (buf_state & BM_DIRTY)

src/backend/storage/buffer/bufmgr.c

Lines changed: 103 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
/* Bits in SyncOneBuffer's return value */
8484
#define BUF_WRITTEN 0x01
8585
#define BUF_REUSABLE 0x02
86+
#define BUF_COOLED 0x04
8687

8788
#define RELS_BSEARCH_THRESHOLD 20
8889

@@ -634,7 +635,7 @@ static void PinBuffer_Locked(BufferDesc *buf);
634635
static void UnpinBuffer(BufferDesc *buf);
635636
static void UnpinBufferNoOwner(BufferDesc *buf);
636637
static void BufferSync(int flags);
637-
static int SyncOneBuffer(int buf_id, bool skip_recently_used,
638+
static int SyncOneBuffer(int buf_id, bool skip_recently_used, bool cool_if_hot,
638639
WritebackContext *wb_context);
639640
static void WaitIO(BufferDesc *buf);
640641
static void AbortBufferIO(Buffer buffer);
@@ -2333,7 +2334,10 @@ BufferAlloc(SMgrRelation smgr, char relpersistence, ForkNumber forkNum,
23332334
* checkpoints, except for their "init" forks, which need to be treated
23342335
* just like permanent relations.
23352336
*/
2336-
set_bits |= BM_TAG_VALID | BUF_USAGECOUNT_ONE;
2337+
set_bits |= BM_TAG_VALID;
2338+
/* Admit the newly loaded page COOL (probation); a second access via
2339+
* PinBuffer promotes it to HOT. This is what makes a one-touch scan
2340+
* self-evicting -- see the cooling-state notes in buf_internals.h. */
23372341
if (relpersistence == RELPERSISTENCE_PERMANENT || forkNum == INIT_FORKNUM)
23382342
set_bits |= BM_PERMANENT;
23392343

@@ -3002,7 +3006,9 @@ ExtendBufferedRelShared(BufferManagerRelation bmr,
30023006

30033007
victim_buf_hdr->tag = tag;
30043008

3005-
set_bits |= BM_TAG_VALID | BUF_USAGECOUNT_ONE;
3009+
set_bits |= BM_TAG_VALID;
3010+
/* Admit COOL (probation); see the comment at the other admission
3011+
* site and the cooling-state notes in buf_internals.h. */
30063012
if (bmr.relpersistence == RELPERSISTENCE_PERMANENT || fork == INIT_FORKNUM)
30073013
set_bits |= BM_PERMANENT;
30083014

@@ -3332,21 +3338,17 @@ PinBuffer(BufferDesc *buf, BufferAccessStrategy strategy,
33323338
/* increase refcount */
33333339
buf_state += BUF_REFCOUNT_ONE;
33343340

3335-
if (strategy == NULL)
3336-
{
3337-
/* Default case: increase usagecount unless already max. */
3338-
if (BUF_STATE_GET_USAGECOUNT(buf_state) < BM_MAX_USAGE_COUNT)
3339-
buf_state += BUF_USAGECOUNT_ONE;
3340-
}
3341-
else
3342-
{
3343-
/*
3344-
* Ring buffers shouldn't evict others from pool. Thus we
3345-
* don't make usagecount more than 1.
3346-
*/
3347-
if (BUF_STATE_GET_USAGECOUNT(buf_state) == 0)
3348-
buf_state += BUF_USAGECOUNT_ONE;
3349-
}
3341+
/*
3342+
* Accessing a resident buffer promotes it to HOT (the 2Q rescue):
3343+
* a page loaded COOL on probation becomes part of the hot working
3344+
* set on its second touch. BM_MAX_USAGE_COUNT is
3345+
* BUF_COOLSTATE_HOT (1), so this saturates at HOT and never
3346+
* overflows the field. We also set the second-chance ref bit so
3347+
* the bgwriter's next cooling pass spares this recently-used buffer.
3348+
*/
3349+
if (BUF_STATE_GET_COOLSTATE(buf_state) < BUF_COOLSTATE_HOT)
3350+
buf_state += BUF_COOLSTATE_ONE;
3351+
buf_state |= BUF_REFBIT;
33503352

33513353
if (pg_atomic_compare_exchange_u64(&buf->state, &old_buf_state,
33523354
buf_state))
@@ -3785,7 +3787,7 @@ BufferSync(int flags)
37853787
*/
37863788
if (pg_atomic_read_u64(&bufHdr->state) & BM_CHECKPOINT_NEEDED)
37873789
{
3788-
if (SyncOneBuffer(buf_id, false, &wb_context) & BUF_WRITTEN)
3790+
if (SyncOneBuffer(buf_id, false, false, &wb_context) & BUF_WRITTEN)
37893791
{
37903792
TRACE_POSTGRESQL_BUFFER_SYNC_WRITTEN(buf_id);
37913793
PendingCheckpointerStats.buffers_written++;
@@ -3876,6 +3878,19 @@ BgBufferSync(WritebackContext *wb_context)
38763878
float smoothing_samples = 16;
38773879
float scan_whole_pool_milliseconds = 120000.0;
38783880

3881+
/*
3882+
* The cleaner scan directly observes the reusable (COOL, unpinned) buffer
3883+
* density over the region it walks, which -- with the cooling-stage
3884+
* evictor -- is exactly the sweep's victim predicate. That observation is
3885+
* ground truth for the buffers about to be reused, whereas the strategy
3886+
* scan's positional proxy (strategy_delta/recent_alloc) blurs a pool whose
3887+
* COOL population is spatially clustered (a scan burst leaves whole regions
3888+
* COOL, hot OLTP regions not). So we let the cleaner's own sample adapt on
3889+
* a shorter window than the strategy proxy, tracking a burst of
3890+
* probationary/scan COOL pages within a cycle or two instead of lagging it.
3891+
*/
3892+
float cleaner_smoothing_samples = 4;
3893+
38793894
/* Used to compute how far we scan ahead */
38803895
long strategy_delta;
38813896
int bufs_to_lap;
@@ -3889,6 +3904,7 @@ BgBufferSync(WritebackContext *wb_context)
38893904
int num_to_scan;
38903905
int num_written;
38913906
int reusable_buffers;
3907+
int write_limit;
38923908

38933909
/* Variables for final smoothed_density update */
38943910
long new_strategy_delta;
@@ -4063,8 +4079,22 @@ BgBufferSync(WritebackContext *wb_context)
40634079
* Now write out dirty reusable buffers, working forward from the
40644080
* next_to_clean point, until we have lapped the strategy scan, or cleaned
40654081
* enough buffers to match our estimate of the next cycle's allocation
4066-
* requirements, or hit the bgwriter_lru_maxpages limit.
4067-
*/
4082+
* requirements, or hit the write limit.
4083+
*
4084+
* The per-cycle write cap is normally bgwriter_lru_maxpages. But under a
4085+
* bulk-dirtying workload (COPY, bulk UPDATE, VACUUM) the pool fills with
4086+
* dirty COOL buffers faster than that fixed cap can clean, so the
4087+
* foreground clock sweep is forced to flush dirty victims inline -- the
4088+
* very cost the cooling-stage evictor is meant to keep off the critical
4089+
* path. When predicted demand (upcoming_alloc_est) exceeds the fixed cap,
4090+
* raise the limit to meet demand so the bgwriter stays ahead and supplies
4091+
* clean victims. This stays bounded (by demand and by lapping the
4092+
* strategy point), so it cannot run away, and normal workloads are
4093+
* unaffected because there upcoming_alloc_est <= bgwriter_lru_maxpages.
4094+
*/
4095+
write_limit = bgwriter_lru_maxpages;
4096+
if (upcoming_alloc_est > write_limit)
4097+
write_limit = upcoming_alloc_est;
40684098

40694099
num_to_scan = bufs_to_lap;
40704100
num_written = 0;
@@ -4073,7 +4103,13 @@ BgBufferSync(WritebackContext *wb_context)
40734103
/* Execute the LRU scan */
40744104
while (num_to_scan > 0 && reusable_buffers < upcoming_alloc_est)
40754105
{
4076-
int sync_state = SyncOneBuffer(next_to_clean, true,
4106+
/*
4107+
* cool_if_hot = false: with the cool-in-place foreground sweep, the
4108+
* clock hand demotes HOT buffers on every tick, so the bgwriter does
4109+
* writeback only and leaves all cooling to the sweep (no background
4110+
* pre-cooling, no double-cooling).
4111+
*/
4112+
int sync_state = SyncOneBuffer(next_to_clean, true, false,
40774113
wb_context);
40784114

40794115
if (++next_to_clean >= NBuffers)
@@ -4086,7 +4122,7 @@ BgBufferSync(WritebackContext *wb_context)
40864122
if (sync_state & BUF_WRITTEN)
40874123
{
40884124
reusable_buffers++;
4089-
if (++num_written >= bgwriter_lru_maxpages)
4125+
if (++num_written >= write_limit)
40904126
{
40914127
PendingBgWriterStats.maxwritten_clean++;
40924128
break;
@@ -4121,7 +4157,7 @@ BgBufferSync(WritebackContext *wb_context)
41214157
{
41224158
scans_per_alloc = (float) new_strategy_delta / (float) new_recent_alloc;
41234159
smoothed_density += (scans_per_alloc - smoothed_density) /
4124-
smoothing_samples;
4160+
cleaner_smoothing_samples;
41254161

41264162
#ifdef BGW_DEBUG
41274163
elog(DEBUG2, "bgwriter: cleaner density alloc=%u scan=%ld density=%.2f new smoothed=%.2f",
@@ -4140,16 +4176,26 @@ BgBufferSync(WritebackContext *wb_context)
41404176
* If skip_recently_used is true, we don't write currently-pinned buffers, nor
41414177
* buffers marked recently used, as these are not replacement candidates.
41424178
*
4179+
* If cool_if_hot is true (the bgwriter's LRU scan), an unpinned HOT buffer is
4180+
* demoted HOT -> COOL as we pass it, pre-staging eviction candidates so the
4181+
* foreground clock sweep finds a COOL victim in a single pass instead of
4182+
* having to cool buffers itself (force_cool). The demotion is done under the
4183+
* buffer header lock we already hold, so it needs no CAS and cannot race a
4184+
* concurrent demotion. A concurrent PinBuffer promotes it back to HOT, which
4185+
* is the intended 2Q behavior (a re-accessed buffer is rescued).
4186+
*
41434187
* Returns a bitmask containing the following flag bits:
41444188
* BUF_WRITTEN: we wrote the buffer.
41454189
* BUF_REUSABLE: buffer is available for replacement, ie, it has
4146-
* pin count 0 and usage count 0.
4190+
* pin count 0 and is COOL (an eviction candidate).
4191+
* BUF_COOLED: we demoted this buffer HOT -> COOL this call.
41474192
*
41484193
* (BUF_WRITTEN could be set in error if FlushBuffer finds the buffer clean
41494194
* after locking it, but we don't care all that much.)
41504195
*/
41514196
static int
4152-
SyncOneBuffer(int buf_id, bool skip_recently_used, WritebackContext *wb_context)
4197+
SyncOneBuffer(int buf_id, bool skip_recently_used, bool cool_if_hot,
4198+
WritebackContext *wb_context)
41534199
{
41544200
BufferDesc *bufHdr = GetBufferDescriptor(buf_id);
41554201
int result = 0;
@@ -4171,8 +4217,38 @@ SyncOneBuffer(int buf_id, bool skip_recently_used, WritebackContext *wb_context)
41714217
*/
41724218
buf_state = LockBufHdr(bufHdr);
41734219

4220+
/*
4221+
* Pre-cool with a second chance: if asked, act on an unpinned HOT buffer.
4222+
* If its ref bit is set (accessed since our last pass), clear the ref bit
4223+
* and leave it HOT -- a recently-used buffer earns one reprieve, keeping
4224+
* the hot working set out of the COOL stage under scan pressure. Only a
4225+
* HOT buffer whose ref bit is already clear is demoted HOT -> COOL,
4226+
* pre-staging it as an eviction candidate for the foreground sweep. We
4227+
* hold the header lock, so each transition is a plain masked store applied
4228+
* atomically by UnlockBufHdrExt; we re-lock to continue the dirty-write
4229+
* inspection below.
4230+
*/
4231+
if (cool_if_hot &&
4232+
BUF_STATE_GET_REFCOUNT(buf_state) == 0 &&
4233+
BUF_STATE_GET_COOLSTATE(buf_state) != BUF_COOLSTATE_COOL)
4234+
{
4235+
if (BUF_STATE_GET_REFBIT(buf_state))
4236+
{
4237+
/* second chance: consume the ref bit, stay HOT */
4238+
UnlockBufHdrExt(bufHdr, buf_state, 0, BUF_REFBIT, 0);
4239+
buf_state = LockBufHdr(bufHdr);
4240+
}
4241+
else
4242+
{
4243+
/* not re-accessed since last pass: demote to COOL */
4244+
UnlockBufHdrExt(bufHdr, buf_state, 0, BUF_USAGECOUNT_MASK, 0);
4245+
buf_state = LockBufHdr(bufHdr);
4246+
result |= BUF_COOLED;
4247+
}
4248+
}
4249+
41744250
if (BUF_STATE_GET_REFCOUNT(buf_state) == 0 &&
4175-
BUF_STATE_GET_USAGECOUNT(buf_state) == 0)
4251+
BUF_STATE_GET_COOLSTATE(buf_state) == BUF_COOLSTATE_COOL)
41764252
{
41774253
result |= BUF_REUSABLE;
41784254
}

0 commit comments

Comments
 (0)