Skip to content

test_bufmgr - eviction algorithm buffer manager core tests#47

Open
cooltodinesh wants to merge 7 commits into
gburd:bcsfrom
cooltodinesh:feat/test-bufmgr-tier1
Open

test_bufmgr - eviction algorithm buffer manager core tests#47
cooltodinesh wants to merge 7 commits into
gburd:bcsfrom
cooltodinesh:feat/test-bufmgr-tier1

Conversation

@cooltodinesh

@cooltodinesh cooltodinesh commented Jul 15, 2026

Copy link
Copy Markdown

Summary

Follow-up to your review on #44. This grows the test_bufmgr module into
tier-1/core properties any correct buffer manager must have, expressed only
through observable behavior (residency, contents, pins), never through
internal field encodings. Tier 2 (bcs-specific HOT/COOL mechanics from #44,
reading the coolstate bit) has been split onto a separate branch/PR so it
can ride with this series while Tier 1 stands on its own.

What's here

Seven regress tests and one isolation spec:

  • eviction_reload — an evicted page reloads with correct contents, and
    the manager makes progress under pressure (no "no unpinned buffers" error).
  • dirty_persistence — an evicted dirty page reloads with its modified
    contents. No checkpoint or restart needed: eviction itself forces the
    write-out (verified directly that CHECKPOINT clears the dirty bit
    without evicting, so a background checkpoint stealing the write would be
    silent, not a failure — see README for the exact mechanism).
  • scan_no_retain — a one-touch page does not indefinitely retain its
    buffer under pressure (the universal half of scan resistance).
  • truncate_stale / drop_recreate_stale — TRUNCATE and DROP+recreate
    never let stale cached pages resurface.
  • local_eviction — the local (temp-table) buffer pool reuses correctly;
    sized from temp_buffers in bytes so it holds at any BLCKSZ.
  • churn — contents stay correct after many replacement cycles over a
    working set several times the pool.
  • pinned_safety (isolation) — a buffer pinned via a suspended cursor in
    one session survives a concurrent flood in another session, with an
    unpinned control probe confirming the flood actually exceeded the pool
    (guards against a vacuous pass).

What makes this portable rather than bcs-specific

  • Pressure via pg_prewarm('rel', 'buffer'), not plain scans. Prewarm's
    buffer mode pulls blocks in through the normal replacement path with no
    BufferAccessStrategy ring, unlike a sequential scan/COPY, which would
    use the ring on stock master and so never cycle the whole pool there.
  • Sizes derived from GUCs in bytes (shared_buffers/temp_buffers ×
    block_size), not a hand-tuned row count, so working sets exceed the
    pool at any BLCKSZ rather than at one config.
  • Self-calibrating floods: several tests loop, adding pressure until
    the observed end-state occurs (a tracked block goes non-resident),
    bounded by a generous round cap, instead of asserting a fixed size
    happened to be right.

Scan resistance: attempted and dropped

I tried a Tier-1 "hot page survives a one-touch flood" test and pulled it
after validating on stock master and across BLCKSZ:

  • It's not universal — on stock master a hot page is not kept resident
    against a greater-than-pool flood at the test pool size (the flood's own
    bulk read/write pressure churns the pool there); it's a property of a
    scan-resistant policy like this branch's cooling evictor, not of every
    correct buffer manager.
  • Even where it holds, it's geometry-dependent and non-monotonic: over the
    same 8MB pool the hot page survived at 1024 buffers (8kB BLCKSZ) but was
    evicted at both 256 (32kB) and 2048 (4kB) buffers, because the
    one-reprieve evictor drops a page whose slot a single greater-than-pool
    sweep revisits a second time, regardless of how hot it is.

It's recorded as a TODO in the README — a real version needs
injection-point control over buffer placement (or a comparative hit-ratio
measure), not a single post-flood residency check.

Verified

  • Green and deterministic (repeated runs, no flakiness) on: this branch
    (bcs), stock master (currently 20devel), and REL_17_STABLE.
  • Green at BLCKSZ 4/8/16/32 kB.
  • NOTICE output from CREATE EXTENSION IF NOT EXISTS / DROP ... IF EXISTS is suppressed via client_min_messages = warning in the test
    config (server-level, not a per-statement SET), so expected output
    isn't coupled to schedule order.

Happy to iterate based on your read of the scan-resistance tradeoff, or on
anything else.

gburd and others added 7 commits July 15, 2026 10:53
Keeps gburd/postgres rebased hourly on postgres/postgres master with
only .github/ changes on top (sync-upstream automatic + manual).

Drops the bespoke Windows dependency-builder workflow: Windows is
already built and tested in CI by upstream's pg-ci.yml (Visual Studio
+ MinGW meson jobs), so a separate dependency prebuild that nothing
consumed was redundant.
The Open Code Review system: ocr-review and ocr-model-check workflows
plus .github/ocr config (LiteLLM->Bedrock Claude Opus 4.8, rule.json,
context.md, pg-history.py).
This isn't a commit that will be submitting for review, it is purely for
local developer tooling while developing this patch set.  Ignore it.
StrategyGetBuffer() advances the shared clock hand, nextVictimBuffer, with a
pg_atomic_fetch_add_u32(..., 1) on every tick.  On a multi-socket system the
cache line holding that counter has to travel over the interconnect on each
operation, pushing a sweep tick from ~20ns (same-socket, line warm in L1/L2)
into the ~100-200ns range.  Under eviction pressure with hundreds of backends
in StrategyGetBuffer() concurrently, that single cache line becomes the
dominant cost of the sweep, visible as elevated bus-cycles and cache-misses in
a perf profile.

Have each backend claim a run of consecutive buffer IDs from the shared hand
with a single fetch-add and then iterate through them privately.  The sweep
still advances through the pool in order, each buffer is still visited exactly
once per complete pass, and the meaning of the clock state is unchanged; only
the temporal ordering of visits within a pass changes, which the algorithm
does not depend on.  The contended atomic now fires roughly once per batch
rather than once per buffer.

The batch is one cache line's worth of clock-hand values --
PG_CACHE_LINE_SIZE / sizeof(uint32) -- capped at NBuffers so a claim can never
wrap the pool more than once.  Batching only helps when the counter's cache
line actually bounces between sockets, so it is enabled only on multi-node
NUMA hardware (pg_numa_get_max_node() >= 1); on a single socket, or where
libnuma is unavailable, the batch size stays 1 and the code path is
byte-identical to the stock clock sweep.

Wraparound handling is adjusted: with batching, several backends can each see
a fetch-add return a value past NBuffers within the same pass.  Any such
backend takes buffer_strategy_lock, re-reads the counter, and if it is still
out of range wraps it with a single CAS and increments completePasses.
StrategySyncStart() continues to see a consistent (nextVictimBuffer,
completePasses) pair.

This is the batched-clock-sweep idea from Jim Mlodgenski's pgsql-hackers
thread, adapted to derive the batch size from the platform cache-line size.

Co-authored-by: Jim Mlodgenski <mlodj@amazon.com>
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.
The cooling-stage evictor admits demand-loaded pages COOL and promotes them to
HOT only on a second access, so a one-touch sequential scan fills and drains
the COOL stage without displacing the hot working set.  Scan resistance is
therefore a property of the replacement algorithm itself, and the
BufferAccessStrategy ring buffers that previously provided it are dead weight.
Remove them end to end.

Deleted:
  - the BufferAccessStrategy type and the BufferAccessStrategyType enum
    (BAS_NORMAL/BULKREAD/BULKWRITE/VACUUM);
  - the ring machinery in freelist.c (GetAccessStrategy[WithSize],
    GetAccessStrategyBufferCount, GetAccessStrategyPinLimit,
    FreeAccessStrategy, GetBufferFromRing, AddBufferToRing,
    StrategyRejectBuffer, IOContextForStrategy);
  - the strategy parameter from ReadBufferExtended, ReadBufferWithoutRelcache,
    the ExtendBufferedRel* family, StrategyGetBuffer, read_stream_begin_*,
    and every scan/vacuum/analyze/index-AM caller;
  - the strategy fields on HeapScanDescData, IndexScanDescData,
    BulkInsertStateData, ReadBuffersOperation, and ReadStream;
  - _hash_getbuf_with_strategy (identical to _hash_getbuf without a strategy).

pg_stat_io's per-strategy IO contexts collapse: IOCONTEXT_BULKREAD,
IOCONTEXT_BULKWRITE and IOCONTEXT_VACUUM are removed, leaving IOCONTEXT_INIT
and IOCONTEXT_NORMAL.  IOOP_REUSE only ever occurred while recycling a ring
buffer, so it is no longer tracked; GetVictimBuffer counts IOOP_EVICT only.

The vacuum_buffer_usage_limit GUC and the VACUUM/ANALYZE (BUFFER_USAGE_LIMIT
...) option are removed, along with the VacuumBufferUsageLimit global, the
ring-size plumbing through VacuumParams and parallel vacuum, and vacuumdb's
--buffer-usage-limit client option.  read_stream's per-backend pin budget is
now enforced solely by GetPinLimit()/GetLocalPinLimit(), which already applied
and is unchanged for the (formerly universal) no-strategy case.

Documentation and the stats/amcheck regression tests are updated to drop the
removed contexts and options.
Per Greg Burd's review on PR gburd#44 (gburd/postgres), grow the existing
bcs-only test_bufmgr module into a durable, algorithm-agnostic test
suite for the buffer manager subsystem. This commit is the Tier-1
half: properties any correct buffer manager must have, expressed only
through observable behavior (residency, contents, pins), never
through internal field encodings. The Tier-2 half -- mechanics
specific to this branch's HOT/COOL cooling-stage evictor -- has been
split onto feat/test-bufmgr-cooling, so a future replacement algorithm
touches only that branch and this one stays untouched.

Seven regress tests and one isolation spec:

  - eviction_reload: an evicted page reloads with correct contents,
    and the manager makes progress under pressure.
  - dirty_persistence: an evicted dirty page reloads with its
    modified contents (no checkpoint or restart needed -- eviction
    itself forces the write-out).
  - scan_no_retain: a one-touch page does not indefinitely retain its
    buffer under pressure.
  - truncate_stale / drop_recreate_stale: TRUNCATE and DROP+recreate
    never let stale cached pages resurface.
  - local_eviction: the local (temp-table) buffer pool reuses
    correctly, sized from temp_buffers in bytes.
  - churn: contents stay correct after many replacement cycles over a
    working set several times the pool.
  - pinned_safety (isolation): a buffer pinned via a suspended cursor
    in one session survives a concurrent flood in another, with an
    unpinned control probe confirming the flood actually exceeded the
    pool.

Two things make this portable rather than bcs-specific:

  - Pressure is generated with pg_prewarm in 'buffer' mode, which
    pulls blocks into shared_buffers through the normal replacement
    path with no BufferAccessStrategy ring -- unlike a plain
    sequential scan, which would use the ring on stock master and so
    never cycle the whole pool there.
  - Fixed-size working sets are derived from the relevant GUC in
    bytes (shared_buffers / temp_buffers times block_size) rather
    than a hand-tuned row count, so they hold at any BLCKSZ.

A scan-resistance test ("a hot page survives a one-touch flood") was
attempted and dropped: it is not a universal property (fails on stock
master, where the bulk-read ring rather than an aging policy provides
scan resistance) and is geometry-dependent and non-monotonic even on
this branch. It's recorded as a TODO in the README pending
injection-point control over buffer placement.

Verified green and deterministic (4+ repeated runs, no flakiness) on:
this branch (bcs), stock master (20devel), REL_17_STABLE, and at
BLCKSZ 4/8/16/32 kB. NOTICE output from CREATE EXTENSION IF NOT EXISTS
and DROP ... IF EXISTS is suppressed via client_min_messages=warning
in the test config, so expected output isn't coupled to schedule
order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cooltodinesh cooltodinesh changed the title test_bufmgr Tier 1: algorithm-agnostic buffer manager tests test_bufmgr - eviction algorithm buffer manager core tests Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants