[PATCH] Batched clock sweep to reduce cross-socket atomic contention

Started by Greg Burd4 months ago20 messageshackers
Beta feature

Hackorum builds and tests every patch posted to the lists, not only commitfest submissions. This is Hackorum's own CI rather than the PostgreSQL project's, and it is still under testing - please report anything that looks wrong.

appliessuccessCI history

You can run a PostgreSQL built from this patch straight from Docker, with no checkout and no build:

docker run --rm -p 5432:5432 ghcr.io/hackorum-dev/postgres-patch:t139487
psql -h localhost -U postgres

Built from patchset v17 (message #17), August 23, 2026 at 02:13 AM.

Every patchset is also pushed to a branch of our PostgreSQL fork, so you can check out the same tree CI built. Without a PostgreSQL checkout:

git clone --branch t139487_17 https://github.com/hackorum-dev/postgres.git

In a checkout you already have, add the fork once:

git remote add hackorum https://github.com/hackorum-dev/postgres.git

then, for this patchset and every later one:

git fetch hackorum t139487_17 && git checkout t139487_17

Patchset v17 (message #17) is on t139487_17

Jump to latest
#1Greg Burd
greg@burd.me

Hello hackers,

A colleague of mine, Jim Mlodgenski, has been poking at NUMA behavior on some of the newer AWS bare-metal instance types (r8i in particular, which exposes 6 NUMA nodes via SNC3 on a 2-socket box), and in the process landed on a very small change to freelist.c that I think is worth showing around. His patch is attached with some tweaks of my own.

Full disclosure: the exploration that led Jim to this patch idea was done with help from an AI assistant (Kiro); the idea, the benchmarking, and the final shape of the patch are human-driven, but I wanted to be up front about how his investigation started. Happy to discuss that separately if people want to.

The one-line summary: instead of advancing nextVictimBuffer one buffer at a time via pg_atomic_fetch_add_u32, each backend claims a batch of 64 consecutive buffer IDs from the shared hand and then iterates them privately. Global sweep order is preserved -- every buffer is still
visited exactly once per complete pass -- but the atomic contention on that one cache line drops by roughly the batch size.

Why this matters
----------------

On multi-socket boxes under eviction pressure, every backend that needs a victim buffer ends up CAS'ing the same cache line. On a single socket, a locked RMW on that cache line stays warm in L1/L2 and completes in ~20ns. On 2+ sockets, the line bounces over QPI/UPI at ~100-200ns per op, and with hundreds of backends running StrategyGetBuffer() concurrently, the line ping-pongs constantly. It's a textbook NUMA scalability bottleneck, and once shared_buffers is smaller than the working set and the sweep is running continuously, that single atomic is what you hit in a perf profile (elevated bus-cycles, cache-misses on the cache line holding nextVictimBuffer).

Andres pointed at the same spot in his pgconf.eu 2024 talk, and Tomas called it out in the "Adding basic NUMA awareness" thread [1]/messages/by-id/099b9433-2855-4f1b-b421-d078a5d82017@vondra.me -- so this isn't news to anyone who's been looking at this area. What I think is new is a fix that's just this, without any of the surrounding architectural change.

The framing (credit to Jim): the clock hand is doing two jobs. It *coordinates* backends so they don't redundantly decrement usage_count on the same buffers and so they eventually visit every buffer in the pool exactly once per pass. It also *serializes* access to the counter. Coordination is the part we want. Serialization is the part that's killing us on bigger NUMA boxes. Batching keeps the coordination and thins out the serialization.

How it works
------------

Two per-backend statics, MyBatchPos and MyBatchEnd. When a backend calls ClockSweepTick() and its local batch is exhausted, it does a single fetch-add of CLOCK_SWEEP_BATCH_SIZE (64) against nextVictimBuffer and now owns that range. Subsequent ticks just bump the local counter.

Wraparound got a small rewrite. The original code had the backend that crossed NBuffers drive completePasses++ under the spinlock via a CAS loop. With batching, multiple backends can each land a fetch-add that returns a value >= NBuffers in the same pass, so the logic now is: whoever sees a start >= NBuffers takes the spinlock, re-reads the counter, and if it's still out of range does a single CAS to wrap it and bumps completePasses. If somebody else already wrapped, we just release and move on. StrategySyncStart() still sees a consistent (nextVictimBuffer, completePasses) pair.

The batch size is gated on whether we actually have multiple NUMA nodes. On a single-socket box the atomic is already socket-local, batching just makes backends skip further ahead than they need to, so we fall back to batch size 1 -- which is bit-for-bit the original behavior. The guard:

if (pg_numa_init() != -1 && pg_numa_get_max_node() >= 1)
ClockSweepBatchSize = Min(CLOCK_SWEEP_BATCH_SIZE, (uint32) NBuffers);
else
ClockSweepBatchSize = 1;

Min() against NBuffers covers the small-shared_buffers corner so a batch never wraps the pool multiple times in one claim.

Does batching mess up the meaning of usage_count?
--------------------------------------------------

Short answer: no. I want to walk through this because it was my first concern too, and I think it's the question that will come up most on review.

The clock sweep's usage_count is an access-frequency approximation measured in units of *complete passes*. A buffer with usage_count = N survives N passes without a re-pin. The semantic meaning lives at pass granularity, not at individual-buffer granularity.

What batching changes: intra-pass temporal ordering. Without batching, with N backends sweeping, decrements are interleaved -- backend A hits B[0], backend B hits B[1]/messages/by-id/099b9433-2855-4f1b-b421-d078a5d82017@vondra.me, backend C hits B[2]/messages/by-id/f0e3c02e-e217-4f04-8dab-1e7e80a228c0@burd.me. With batching, backend A hits B[0..63] in a tight local burst, then backend B hits B[64..127], etc. The 64-buffer chunks are decremented in bursts rather than individually.

Why it doesn't matter:

1. Every buffer still gets decremented exactly once per complete
pass. The invariant the algorithm actually depends on is
untouched.

2. A buffer's survival window is the time between consecutive
passes. That's milliseconds to seconds under load. Whether
B[0] gets decremented 50us before or 50us after B[63] within
the same pass is below the resolution of anything usage_count
is trying to measure.

3. The bgwriter's feedback loop reads (nextVictimBuffer,
completePasses, numBufferAllocs) via StrategySyncStart() every
~200ms. nextVictimBuffer still advances at the same *total*
rate (64 per atomic op, but atomic ops happen 1/64 as often).
The position it reports can jitter by up to 64 buffers relative
to the one-at-a-time case, but BgBufferSync()'s smoothed
estimates operate over thousands of buffers per cycle, so the
jitter disappears into the averaging. numBufferAllocs still
increments once per allocation. strategy_delta,
smoothed_alloc, smoothed_density, reusable_buffers_est -- all
unaffected in any way I can see.

Table form, because it's easier to argue with:

Property | Unpatched | Batched
----------------------------------+----------------+----------------
Buffers visited per pass | NBuffers | NBuffers
Decrements per buffer per pass | 1 | 1
Eviction threshold | usage_count==0 | usage_count==0
Max survival (passes) | 6 | 6
Decrement ordering within a pass | interleaved | chunked
bgwriter allocation rate signal | accurate | accurate
Cross-socket atomic traffic | 1 per buffer | 1 per 64

There is one subtle difference worth naming. When a backend finds a victim at B[5] of its batch, it returns with MyBatchEnd still sitting at B[63]. The next time that backend needs a victim it resumes at B[6], not at wherever the global hand now points. So the backend drains its batch over multiple StrategyGetBuffer() calls rather than all at once. Under heavy load, where batches are consumed in microseconds, this is invisible. Under light load, the implication is that some buffers can sit with slightly stale usage_count for longer than they would have before. But "light load" means "the sweep is barely moving and nothing wants to evict anyway" -- so the effect
doesn't show up where it would hurt.

There's also a small positive side-effect: cache locality. The backend that just touched BufferDescriptor[B[0]] has the adjacent descriptors warm in L1/L2. Walking B[0..63] locally is cheaper than walking a striped interleaving where each descriptor was last touched by a different core. I haven't tried to isolate this in perf, but it falls out naturally.

Benchmarks
----------

Jim ran these; I'm still working on reproducing them locally and will post independent numbers in a follow-up. All bare metal, Linux, huge pages enabled throughout (more on that below), postmaster pinned to node 0 with `numactl --cpunodebind=0` because otherwise stock TPS varied from 31K to 40K depending on which node the postmaster happened to land on at launch -- worth flagging for anyone trying to reproduce.

Workload is pgbench scale 3000 (~45GB) with shared_buffers=32GB, so the working set always spills and the sweep is hot.

r8i.metal-96xl (384 vCPUs, 2 sockets, 6 NUMA nodes via SNC3):

pgbench RO:
Clients Stock Patched Delta
64 31,457 36,353 +16%
128 31,678 37,864 +20%
256 31,510 37,558 +19%
384 31,431 37,464 +19%
512 31,329 37,040 +18%

pgbench RW:
Clients Stock Patched Delta
64 7,685 7,713 0%
128 10,420 10,541 +1%
256 12,393 12,463 +1%
384 15,317 15,197 -1%
512 17,930 17,978 0%

m6i.metal (128 vCPUs, 2 sockets, Ice Lake):
RO +19-20%, RW within noise.

c8i.metal-48xl (192 vCPUs, 1 socket):
Single-socket -> batch_size=1 -> original code path. No
behavioral change. (I double-checked this one specifically
because it's the sanity test for the gate.)

HammerDB TPC-C on m6i.metal (1000 warehouses):
VUs Stock Patched Delta
128 358,518 349,787 -2%
256 332,098 330,272 -1%
384 365,782 377,519 +3%
512 370,663 386,526 +4%

No TPC-C regression, which was the thing we were most worried about. An earlier attempt (per-socket partitioned sweep, see below) was -13% on this same workload.

The general shape is: the scaling curve flattens later. Unpatched, TPS tops out around 128 clients and stays flat up to 512 because backends are spending cycles waiting on the cache line rather than
doing work. Patched, the curve keeps rising past the point where unpatched plateaus.

Huge pages caveat: all of the above was run with huge pages on, on large-memory instances (the r8i.96xl has 3TB, so Jim never considered running without them). We have not characterized the non-huge-pages case. That's on my list; I don't expect it to change the conclusion, but I shouldn't speak for data I haven't collected.

Relationship to Tomas's NUMA series
-----------------------------------

Tomas posted a multi-patch NUMA-awareness series in [1]/messages/by-id/099b9433-2855-4f1b-b421-d078a5d82017@vondra.me covering buffer interleaving across nodes, partitioned freelists, partitioned clock sweep, PGPROC interleaving, and related pieces. I want to be careful here because I don't think we should frame this patch as competing with that work.

One thing I found striking as I re-read the thread: in the benchmarks Tomas posted later in the series, *most of the benefit comes from partitioning the clock sweep*, and the NUMA memory-placement layer on top sometimes runs slower than partitioning alone. His own conclusion, quoted roughly: the benefit mostly comes from just partitioning the clock sweep, and it's largely independent of the NUMA stuff; the NUMA partitioning is often slower.

That observation is the thing that makes me think batching is worth considering on its own. It's going after the same bottleneck Tomas's partitioning addresses, but:

- without splitting global eviction visibility (which is where
cross-partition stealing gets complicated),
- without requiring NUMA-aware buffer placement (which has huge
page alignment, descriptor-partition-mid-page, and resize
complications that are still being worked out in that thread),
- without touching PGPROC or bgwriter.

What this patch does *not* do:
- place buffers on specific NUMA nodes
- partition the freelist
- touch PGPROC
- add new GUCs
- change bgwriter

What this patch *does* do:
- target exactly the clock-sweep contention that Tomas's
partitioning targets, and reduce it by ~64x, in ~30 lines.

If Tomas's series lands in full, this patch becomes redundant for its primary use case (though even within a partitioned sweep, the per-partition atomic still benefits from batching, so it's arguably a useful primitive either way). If Tomas's series lands incrementally over several cycles -- which the open items in that thread suggest is the realistic path -- this gets us a real chunk of the multi-socket win now.

This patch is also orthogonal to my earlier thread about removing the freelist entirely [2]/messages/by-id/f0e3c02e-e217-4f04-8dab-1e7e80a228c0@burd.me, but given the proximity to that code Jim agreed that I could propose/steward it here on the list for consideration.

Open questions / things I'd like feedback on
--------------------------------------------

- Batch size. 64 is a round number that worked well in testing, but
Nathan raised the reasonable point that on small shared_buffers
with high concurrency, a fixed 64 could be unfortunate. Options:
scale with shared_buffers (Min(64, NBuffers / N) for some N), scale
with max_connections, keep it fixed but let operators tune it, or
make it a function of NUMA node count. I don't have a strong
opinion yet; the Min(batch, NBuffers) cap covers the "obviously
wrong" corner but doesn't speak to the "several hundred backends
on a few-MB shared_buffers" shape. Numbers/ideas/proposals welcome.

- NUMA detection. The gate uses pg_numa_init() /
pg_numa_get_max_node(). On systems where libnuma isn't available,
or where get_mempolicy is blocked (some container configurations),
we fall back to batch size 1. That's safe but it misses the
"single socket, many cores, still benefits from fewer atomics"
case. Might be worth a way to force-enable, or batching on all
systems with a smaller batch size when single-socket. I'd like to
measure before deciding.

- Eviction pattern on reads. Nathan also flagged that with batching,
the buffers a backend ends up pinning in one StrategyGetBuffer()
call will tend to be contiguous in buffer-id space rather than
scattered, which is a different allocation pattern than today.
The usage_count analysis above says this is benign, but if anyone
has an intuition for a workload where this would be observable
(e.g., something that cares about the mapping between buffer-id
and relation locality), I'd like to hear it.

- nextVictimBuffer wraparound. The current code has a mild overflow
concern papered over with "highly unlikely and wouldn't be
particularly harmful". With batching this is no worse than before,
but if we're already touching this function, it might be worth
thinking about whether to tighten it up in the same patch or a
follow-up.

- Should the non-NUMA value for this be derived from core counts that
imply L1/L2 cache layouts or simply default to 8 rather than 1 to
realize some benefit?

- Should there be a postgresql.conf setting for this that takes
precedence?

I'll run the non-huge-pages variant, reproduce the r8i numbers, poke at the small-shared_buffers corner, and post perf stat output showing the atomic/cache-miss deltas over the next few days. In the meantime, eyeballs and skepticism welcome -- I would especially welcome comments from Andres, who's been in this code recently, and from Tomas, whose series has the most overlap.

I realize that we're past feature freeze and working on release notes for v19, so the chances of merging this are slim to none. I think this could be considered a "performance bug fix for NUMA systems" in this release, but that is stretching it a bit. It is a big ask at this stage to land a change like this.

best.

-greg

[1]: /messages/by-id/099b9433-2855-4f1b-b421-d078a5d82017@vondra.me
[2]: /messages/by-id/f0e3c02e-e217-4f04-8dab-1e7e80a228c0@burd.me

Attachments:

v1-0001-Reduce-clock-sweep-atomic-contention-by-claiming-.patchapplication/octet-stream; name="=?UTF-8?Q?v1-0001-Reduce-clock-sweep-atomic-contention-by-claiming-.patc?= =?UTF-8?Q?h?="Download+94-43
#2Greg Burd
greg@burd.me
In reply to: Greg Burd (#1)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

On Sat, Apr 25, 2026, at 4:08 PM, Greg Burd wrote:

Hello hackers,

Hi again, attached is v2:

0001 - unchanged, batches clock-sweep to reduce contention
0002 - changed ComputeClockBatchSize() such that non-NUMA multi-core systems use batches as well and no longer default to batch size 1

Details below...

A colleague of mine, Jim Mlodgenski, has been poking at NUMA behavior
on some of the newer AWS bare-metal instance types (r8i in particular,
which exposes 6 NUMA nodes via SNC3 on a 2-socket box), and in the
process landed on a very small change to freelist.c that I think is
worth showing around. His patch is attached with some tweaks of my own.

Full disclosure: the exploration that led Jim to this patch idea was
done with help from an AI assistant (Kiro); the idea, the benchmarking,
and the final shape of the patch are human-driven, but I wanted to be
up front about how his investigation started. Happy to discuss that
separately if people want to.

The one-line summary: instead of advancing nextVictimBuffer one buffer
at a time via pg_atomic_fetch_add_u32, each backend claims a batch of
64 consecutive buffer IDs from the shared hand and then iterates them
privately. Global sweep order is preserved -- every buffer is still
visited exactly once per complete pass -- but the atomic contention on
that one cache line drops by roughly the batch size.

Why this matters
----------------

On multi-socket boxes under eviction pressure, every backend that needs
a victim buffer ends up CAS'ing the same cache line. On a single
socket, a locked RMW on that cache line stays warm in L1/L2 and
completes in ~20ns. On 2+ sockets, the line bounces over QPI/UPI at
~100-200ns per op, and with hundreds of backends running
StrategyGetBuffer() concurrently, the line ping-pongs constantly. It's
a textbook NUMA scalability bottleneck, and once shared_buffers is
smaller than the working set and the sweep is running continuously,
that single atomic is what you hit in a perf profile (elevated
bus-cycles, cache-misses on the cache line holding nextVictimBuffer).

Andres pointed at the same spot in his pgconf.eu 2024 talk, and Tomas
called it out in the "Adding basic NUMA awareness" thread [1] -- so
this isn't news to anyone who's been looking at this area. What I
think is new is a fix that's just this, without any of the surrounding
architectural change.

The framing (credit to Jim): the clock hand is doing two jobs. It
*coordinates* backends so they don't redundantly decrement usage_count
on the same buffers and so they eventually visit every buffer in the
pool exactly once per pass. It also *serializes* access to the
counter. Coordination is the part we want. Serialization is the part
that's killing us on bigger NUMA boxes. Batching keeps the
coordination and thins out the serialization.

How it works
------------

Two per-backend statics, MyBatchPos and MyBatchEnd. When a backend
calls ClockSweepTick() and its local batch is exhausted, it does a
single fetch-add of CLOCK_SWEEP_BATCH_SIZE (64) against
nextVictimBuffer and now owns that range. Subsequent ticks just bump
the local counter.

Wraparound got a small rewrite. The original code had the backend that
crossed NBuffers drive completePasses++ under the spinlock via a CAS
loop. With batching, multiple backends can each land a fetch-add that
returns a value >= NBuffers in the same pass, so the logic now is:
whoever sees a start >= NBuffers takes the spinlock, re-reads the
counter, and if it's still out of range does a single CAS to wrap it
and bumps completePasses. If somebody else already wrapped, we just
release and move on. StrategySyncStart() still sees a consistent
(nextVictimBuffer, completePasses) pair.

The batch size is gated on whether we actually have multiple NUMA
nodes. On a single-socket box the atomic is already socket-local,
batching just makes backends skip further ahead than they need to, so
we fall back to batch size 1 -- which is bit-for-bit the original
behavior. The guard:

if (pg_numa_init() != -1 && pg_numa_get_max_node() >= 1)
ClockSweepBatchSize = Min(CLOCK_SWEEP_BATCH_SIZE, (uint32) NBuffers);
else
ClockSweepBatchSize = 1;

Min() against NBuffers covers the small-shared_buffers corner so a
batch never wraps the pool multiple times in one claim.

Thinking more about this approach led me to believe that this non-NUMA default is wrong and induces overhead for a very common case.

Does batching mess up the meaning of usage_count?
--------------------------------------------------

Short answer: no. I want to walk through this because it was my first
concern too, and I think it's the question that will come up most on
review.

The clock sweep's usage_count is an access-frequency approximation
measured in units of *complete passes*. A buffer with usage_count = N
survives N passes without a re-pin. The semantic meaning lives at pass
granularity, not at individual-buffer granularity.

What batching changes: intra-pass temporal ordering. Without batching,
with N backends sweeping, decrements are interleaved -- backend A hits
B[0], backend B hits B[1], backend C hits B[2]. With batching, backend
A hits B[0..63] in a tight local burst, then backend B hits B[64..127],
etc. The 64-buffer chunks are decremented in bursts rather than
individually.

Why it doesn't matter:

1. Every buffer still gets decremented exactly once per complete
pass. The invariant the algorithm actually depends on is
untouched.

2. A buffer's survival window is the time between consecutive
passes. That's milliseconds to seconds under load. Whether
B[0] gets decremented 50us before or 50us after B[63] within
the same pass is below the resolution of anything usage_count
is trying to measure.

3. The bgwriter's feedback loop reads (nextVictimBuffer,
completePasses, numBufferAllocs) via StrategySyncStart() every
~200ms. nextVictimBuffer still advances at the same *total*
rate (64 per atomic op, but atomic ops happen 1/64 as often).
The position it reports can jitter by up to 64 buffers relative
to the one-at-a-time case, but BgBufferSync()'s smoothed
estimates operate over thousands of buffers per cycle, so the
jitter disappears into the averaging. numBufferAllocs still
increments once per allocation. strategy_delta,
smoothed_alloc, smoothed_density, reusable_buffers_est -- all
unaffected in any way I can see.

Table form, because it's easier to argue with:

Property | Unpatched | Batched
----------------------------------+----------------+----------------
Buffers visited per pass | NBuffers | NBuffers
Decrements per buffer per pass | 1 | 1
Eviction threshold | usage_count==0 | usage_count==0
Max survival (passes) | 6 | 6
Decrement ordering within a pass | interleaved | chunked
bgwriter allocation rate signal | accurate | accurate
Cross-socket atomic traffic | 1 per buffer | 1 per 64

There is one subtle difference worth naming. When a backend finds a
victim at B[5] of its batch, it returns with MyBatchEnd still sitting
at B[63]. The next time that backend needs a victim it resumes at
B[6], not at wherever the global hand now points. So the backend
drains its batch over multiple StrategyGetBuffer() calls rather than
all at once. Under heavy load, where batches are consumed in
microseconds, this is invisible. Under light load, the implication is
that some buffers can sit with slightly stale usage_count for longer
than they would have before. But "light load" means "the sweep is
barely moving and nothing wants to evict anyway" -- so the effect
doesn't show up where it would hurt.

There's also a small positive side-effect: cache locality. The backend
that just touched BufferDescriptor[B[0]] has the adjacent descriptors
warm in L1/L2. Walking B[0..63] locally is cheaper than walking a
striped interleaving where each descriptor was last touched by a
different core. I haven't tried to isolate this in perf, but it falls
out naturally.

Benchmarks
----------

Jim ran these; I'm still working on reproducing them locally and will
post independent numbers in a follow-up. All bare metal, Linux, huge
pages enabled throughout (more on that below), postmaster pinned to
node 0 with `numactl --cpunodebind=0` because otherwise stock TPS
varied from 31K to 40K depending on which node the postmaster happened
to land on at launch -- worth flagging for anyone trying to reproduce.

Workload is pgbench scale 3000 (~45GB) with shared_buffers=32GB, so the
working set always spills and the sweep is hot.

r8i.metal-96xl (384 vCPUs, 2 sockets, 6 NUMA nodes via SNC3):

pgbench RO:
Clients Stock Patched Delta
64 31,457 36,353 +16%
128 31,678 37,864 +20%
256 31,510 37,558 +19%
384 31,431 37,464 +19%
512 31,329 37,040 +18%

pgbench RW:
Clients Stock Patched Delta
64 7,685 7,713 0%
128 10,420 10,541 +1%
256 12,393 12,463 +1%
384 15,317 15,197 -1%
512 17,930 17,978 0%

m6i.metal (128 vCPUs, 2 sockets, Ice Lake):
RO +19-20%, RW within noise.

c8i.metal-48xl (192 vCPUs, 1 socket):
Single-socket -> batch_size=1 -> original code path. No
behavioral change. (I double-checked this one specifically
because it's the sanity test for the gate.)

HammerDB TPC-C on m6i.metal (1000 warehouses):
VUs Stock Patched Delta
128 358,518 349,787 -2%
256 332,098 330,272 -1%
384 365,782 377,519 +3%
512 370,663 386,526 +4%

No TPC-C regression, which was the thing we were most worried about. An
earlier attempt (per-socket partitioned sweep, see below) was -13% on
this same workload.

The general shape is: the scaling curve flattens later. Unpatched, TPS
tops out around 128 clients and stays flat up to 512 because backends
are spending cycles waiting on the cache line rather than
doing work. Patched, the curve keeps rising past the point where
unpatched plateaus.

Huge pages caveat: all of the above was run with huge pages on, on
large-memory instances (the r8i.96xl has 3TB, so Jim never considered
running without them). We have not characterized the non-huge-pages
case. That's on my list; I don't expect it to change the conclusion,
but I shouldn't speak for data I haven't collected.

Relationship to Tomas's NUMA series
-----------------------------------

Tomas posted a multi-patch NUMA-awareness series in [1] covering buffer
interleaving across nodes, partitioned freelists, partitioned clock
sweep, PGPROC interleaving, and related pieces. I want to be careful
here because I don't think we should frame this patch as competing with
that work.

One thing I found striking as I re-read the thread: in the benchmarks
Tomas posted later in the series, *most of the benefit comes from
partitioning the clock sweep*, and the NUMA memory-placement layer on
top sometimes runs slower than partitioning alone. His own conclusion,
quoted roughly: the benefit mostly comes from just partitioning the
clock sweep, and it's largely independent of the NUMA stuff; the NUMA
partitioning is often slower.

That observation is the thing that makes me think batching is worth
considering on its own. It's going after the same bottleneck Tomas's
partitioning addresses, but:

- without splitting global eviction visibility (which is where
cross-partition stealing gets complicated),
- without requiring NUMA-aware buffer placement (which has huge
page alignment, descriptor-partition-mid-page, and resize
complications that are still being worked out in that thread),
- without touching PGPROC or bgwriter.

What this patch does *not* do:
- place buffers on specific NUMA nodes
- partition the freelist
- touch PGPROC
- add new GUCs
- change bgwriter

What this patch *does* do:
- target exactly the clock-sweep contention that Tomas's
partitioning targets, and reduce it by ~64x, in ~30 lines.

If Tomas's series lands in full, this patch becomes redundant for its
primary use case (though even within a partitioned sweep, the
per-partition atomic still benefits from batching, so it's arguably a
useful primitive either way). If Tomas's series lands incrementally
over several cycles -- which the open items in that thread suggest is
the realistic path -- this gets us a real chunk of the multi-socket win
now.

This patch is also orthogonal to my earlier thread about removing the
freelist entirely [2], but given the proximity to that code Jim agreed
that I could propose/steward it here on the list for consideration.

Open questions / things I'd like feedback on
--------------------------------------------

- Batch size. 64 is a round number that worked well in testing, but
Nathan raised the reasonable point that on small shared_buffers
with high concurrency, a fixed 64 could be unfortunate. Options:
scale with shared_buffers (Min(64, NBuffers / N) for some N), scale
with max_connections, keep it fixed but let operators tune it, or
make it a function of NUMA node count. I don't have a strong
opinion yet; the Min(batch, NBuffers) cap covers the "obviously
wrong" corner but doesn't speak to the "several hundred backends
on a few-MB shared_buffers" shape. Numbers/ideas/proposals welcome.

- NUMA detection. The gate uses pg_numa_init() /
pg_numa_get_max_node(). On systems where libnuma isn't available,
or where get_mempolicy is blocked (some container configurations),
we fall back to batch size 1. That's safe but it misses the
"single socket, many cores, still benefits from fewer atomics"
case. Might be worth a way to force-enable, or batching on all
systems with a smaller batch size when single-socket. I'd like to
measure before deciding.

- Eviction pattern on reads. Nathan also flagged that with batching,
the buffers a backend ends up pinning in one StrategyGetBuffer()
call will tend to be contiguous in buffer-id space rather than
scattered, which is a different allocation pattern than today.
The usage_count analysis above says this is benign, but if anyone
has an intuition for a workload where this would be observable
(e.g., something that cares about the mapping between buffer-id
and relation locality), I'd like to hear it.

- nextVictimBuffer wraparound. The current code has a mild overflow
concern papered over with "highly unlikely and wouldn't be
particularly harmful". With batching this is no worse than before,
but if we're already touching this function, it might be worth
thinking about whether to tighten it up in the same patch or a
follow-up.

- Should the non-NUMA value for this be derived from core counts that
imply L1/L2 cache layouts or simply default to 8 rather than 1 to
realize some benefit?

So, I'm answering my own question here. Yes, it should. Ideas below.

- Should there be a postgresql.conf setting for this that takes
precedence?

I'll run the non-huge-pages variant, reproduce the r8i numbers, poke at
the small-shared_buffers corner, and post perf stat output showing the
atomic/cache-miss deltas over the next few days. In the meantime,
eyeballs and skepticism welcome -- I would especially welcome comments
from Andres, who's been in this code recently, and from Tomas, whose
series has the most overlap.

I realize that we're past feature freeze and working on release notes
for v19, so the chances of merging this are slim to none. I think this
could be considered a "performance bug fix for NUMA systems" in this
release, but that is stretching it a bit. It is a big ask at this
stage to land a change like this.

best.

-greg

[1]
/messages/by-id/099b9433-2855-4f1b-b421-d078a5d82017@vondra.me
[2]
/messages/by-id/f0e3c02e-e217-4f04-8dab-1e7e80a228c0@burd.me
Attachments:
* v1-0001-Reduce-clock-sweep-atomic-contention-by-claiming-.patch

ComputeClockBatchSize() has two phases: select a base batch from hardware topology, then cap it to prevent over-claiming.

Phase 1: Base batch from topology

int ncpus = pg_get_online_cpus();
int numa_nodes = (pg_numa_init() != -1) ? pg_numa_get_max_node() + 1 : 1;

if (numa_nodes > 1) base_batch = 64;
else if (ncpus > 16) base_batch = 32;
else if (ncpus > 8) base_batch = 16;
else if (ncpus > 4) base_batch = 8;
else base_batch = 1;

The reasoning at each tier:

- NUMA (multi-socket): Atomic ops cross the interconnect (QPI/UPI/Infinity
Fabric). Round-trip latency is ~100-300ns vs ~10-40ns intra-socket.
Batch=64 amortizes that heavily.

- >16 cores, single socket: Still significant L3 contention, many cores
competing for the same cache line. Batch=32 cuts atomic ops by 32x.

- 9-16 cores: Moderate contention. Batch=16.

- 5-8 cores: Light contention. Batch=8.

- <=4 cores: Almost no contention. Batch=1 (no batching). The overhead of
batching logic isn't worth it, and there's a fairness tradeoff - batching
means one backend "owns" a range of buffers temporarily, which matters
more when there are few buffers per backend.

Phase 2: Cap to prevent over-claiming

max_batch = (MaxBackends > 0)
? pool_nbuffers / (2 * MaxBackends)
: pool_nbuffers / 200;
if (max_batch < 1)
max_batch = 1;

return Min(base_batch, Min(max_batch, pool_nbuffers));

The cap ensures that if every backend simultaneously claims a batch, the total claimed doesn't exceed half the pool:

batch_size * MaxBackends <= pool_nbuffers / 2

Why half? If all backends claimed the entire pool simultaneously, they'd each be sweeping overlapping ranges, thus wasting work and defeating the purpose. Keeping total claims under 50% of the pool means at any instant, at most half the buffers are "in flight" being evaluated by backends, and the other half are available for normal operation.

For a small dynamic pool (say 4096 buffers with MaxBackends=200), the cap computes to 4096 / 400 = 10, which overrides any larger base_batch. For the default pool with shared_buffers = 8GB (1M buffers) and MaxBackends=200, the cap is 1000000 / 400 = 2500 which is well above the max base_batch of 64, so the base_batch wins.

The pool_nbuffers floor at the end handles the degenerate case of a pool smaller than the batch size.

The Tradeoff

Larger batches reduce atomic contention but increase sweep unevenness, one backend might sweep through "cold" buffers while another's batch happens to land on "hot" ones. The tiered approach balances this: batch aggressively only when the hardware topology makes contention the dominant cost (NUMA, many-core), and stay conservative on small systems where fairness matters more.

I think this is better because:

1. The original patch only batched on multi-socket NUMA systems. The new algorithm also provides atomic contention benefits on large single-socket systems (>16 cores) where L3 cache contention matters.

2. Conservative on small systems: Systems with ≤4 cores get batch_size=1 (original behavior) since batching overhead outweighs contention benefits and fairness matters more.

3. Prevents pathological over-claiming: The cap mechanism prevents scenarios where many backends claim huge batches relative to a small buffer pool.

Based on the algorithm, here's what different systems would get:

System CPUs NUMA Total RAM Shr Buf Batch Size Atomic Reduction
================== ==== ======== =========== ======== ========== ================
r8i.metal-96xl 384 multi 3072GB 2457.6GB 64 64x
m6i.metal 128 multi 512GB 409.6GB 64 64x
c8i.metal-48xl 192 1 socket 192GB 153.6GB 32 32x
Large server 64 multi 256GB 204.8GB 64 64x
Medium server 32 1 socket 64GB 51.2GB 32 32x
Small server 16 1 socket 32GB 25.6GB 16 16x
Developer machine 8 1 socket 16GB 12.8GB 8 8x
Small VM 4 1 socket 4GB 3.2GB 1 no change
Overloaded VM 8 1 socket 4GB 3.2GB 8 8x

best.

-greg

Attachments:

t139487_2
v2-0001-Reduce-clock-sweep-atomic-contention-by-claiming-.patchapplication/octet-stream; name="=?UTF-8?Q?v2-0001-Reduce-clock-sweep-atomic-contention-by-claiming-.patc?= =?UTF-8?Q?h?="Download+94-43
v2-0002-Improve-clock-sweep-batch-sizing-with-CPU-aware-a.patchapplication/octet-stream; name="=?UTF-8?Q?v2-0002-Improve-clock-sweep-batch-sizing-with-CPU-aware-a.patc?= =?UTF-8?Q?h?="Download+77-20
#3Andres Freund
andres@anarazel.de
In reply to: Greg Burd (#1)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

Hi,

Thanks for looking into this.

On 2026-04-25 16:08:02 -0400, Greg Burd wrote:

Does batching mess up the meaning of usage_count?
--------------------------------------------------

Short answer: no. I want to walk through this because it was my first
concern too, and I think it's the question that will come up most on review.

The clock sweep's usage_count is an access-frequency approximation measured
in units of *complete passes*. A buffer with usage_count = N survives N
passes without a re-pin. The semantic meaning lives at pass granularity,
not at individual-buffer granularity.

What batching changes: intra-pass temporal ordering. Without batching, with
N backends sweeping, decrements are interleaved -- backend A hits B[0],
backend B hits B[1], backend C hits B[2]. With batching, backend A hits
B[0..63] in a tight local burst, then backend B hits B[64..127], etc. The
64-buffer chunks are decremented in bursts rather than individually.

Why it doesn't matter:

1. Every buffer still gets decremented exactly once per complete
pass. The invariant the algorithm actually depends on is
untouched.

2. A buffer's survival window is the time between consecutive
passes. That's milliseconds to seconds under load. Whether
B[0] gets decremented 50us before or 50us after B[63] within
the same pass is below the resolution of anything usage_count
is trying to measure.

I don't think this is true, unfortunately. Sure, if you have a completely
uniform, IO intensive, workload it is, but that's not all there is. If you
have a bunch of connections that replace buffers at a low rate and a bunch of
connections that do so at a high rate, the batches "checked out" by the "low
rate" connections won't be processed soon. Thus the buffers in that batch
won't have their usagecount decremented and thus have a stronger "protection"
against replacement.

You can argue that may be OK, because it'd be unlikely that the next sweep
would assign the same buffers to an "low rate" backend again. But that'd be an
argument you'd have to make and validate.

I'm somewhat doubtful that batching that's independent of contention and
independent of the usage rate will work out all that well. If you instead
went for a partitioned sweep architecture, with balancing between the
different partitions, you don't have that issue. And you have a building block
for more numa awareness etc.

There is one subtle difference worth naming. When a backend finds a victim at B[5] of its batch, it returns with MyBatchEnd still sitting at B[63]. The next time that backend needs a victim it resumes at B[6], not at wherever the global hand now points. So the backend drains its batch over multiple StrategyGetBuffer() calls rather than all at once. Under heavy load, where batches are consumed in microseconds, this is invisible. Under light load, the implication is that some buffers can sit with slightly stale usage_count for longer than they would have before. But "light load" means "the sweep is barely moving and nothing wants to evict anyway" -- so the effect
doesn't show up where it would hurt.

As mentioned above, this assumes that the replacement rate is uniform between
backends, which I think is not uniformly true outside of benchmarks.

There's also a small positive side-effect: cache locality. The backend that
just touched BufferDescriptor[B[0]] has the adjacent descriptors warm in
L1/L2.

A BufferDesc is 64bytes. With common cacheline sizes and stuff like adjacent
cacheline prefetching you'll have *maybe* 2 consecutive BufferDescs in L1/L2.
Where it might help more is the TLB.

Benchmarks
----------

Jim ran these; I'm still working on reproducing them locally and will post
independent numbers in a follow-up. All bare metal, Linux, huge pages
enabled throughout (more on that below), postmaster pinned to node 0 with
`numactl --cpunodebind=0` because otherwise stock TPS varied from 31K to 40K
depending on which node the postmaster happened to land on at launch --
worth flagging for anyone trying to reproduce.

That's an odd one that I think you need to investigate separately.

Workload is pgbench scale 3000 (~45GB) with shared_buffers=32GB, so the
working set always spills and the sweep is hot.

Uhm, is this something worth optimizing substantially for? What you're
measuring here is basically the worst possible way of configuring a database,
with full double buffering and a lot of memory bandwidth dedicated to copying
buffers from one place to another. That's maybe a sane setup if you have a lot
of small databases that you can't configure individually, but that's not the
case when you run a reasonably large workload on a 384vCPU setup.

I think to be really convincing you'd have to do this with actual IO involved
somewhere.

Relationship to Tomas's NUMA series
-----------------------------------

Tomas posted a multi-patch NUMA-awareness series in [1] covering buffer interleaving across nodes, partitioned freelists, partitioned clock sweep, PGPROC interleaving, and related pieces. I want to be careful here because I don't think we should frame this patch as competing with that work.

One thing I found striking as I re-read the thread: in the benchmarks Tomas
posted later in the series, *most of the benefit comes from partitioning the
clock sweep*, and the NUMA memory-placement layer on top sometimes runs
slower than partitioning alone. His own conclusion, quoted roughly: the
benefit mostly comes from just partitioning the clock sweep, and it's
largely independent of the NUMA stuff; the NUMA partitioning is often
slower.

That was partially because he measured on something that didn't really have
significant NUMA effects though...

That observation is the thing that makes me think batching is worth
considering on its own. It's going after the same bottleneck Tomas's
partitioning addresses, but:

- without splitting global eviction visibility (which is where
cross-partition stealing gets complicated),

You *are* doing that tho.

- without requiring NUMA-aware buffer placement (which has huge
page alignment, descriptor-partition-mid-page, and resize
complications that are still being worked out in that thread),

You can do the partitioned clock sweep without *any* of that.

Greetings,

Andres Freund

#4Greg Burd
greg@burd.me
In reply to: Andres Freund (#3)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

On Mon, Apr 27, 2026, at 10:15 AM, Andres Freund wrote:

Hi,

Thanks for looking into this.

On 2026-04-25 16:08:02 -0400, Greg Burd wrote:

Does batching mess up the meaning of usage_count?
--------------------------------------------------

Short answer: no. I want to walk through this because it was my first
concern too, and I think it's the question that will come up most on review.

The clock sweep's usage_count is an access-frequency approximation measured
in units of *complete passes*. A buffer with usage_count = N survives N
passes without a re-pin. The semantic meaning lives at pass granularity,
not at individual-buffer granularity.

What batching changes: intra-pass temporal ordering. Without batching, with
N backends sweeping, decrements are interleaved -- backend A hits B[0],
backend B hits B[1], backend C hits B[2]. With batching, backend A hits
B[0..63] in a tight local burst, then backend B hits B[64..127], etc. The
64-buffer chunks are decremented in bursts rather than individually.

Why it doesn't matter:

1. Every buffer still gets decremented exactly once per complete
pass. The invariant the algorithm actually depends on is
untouched.

2. A buffer's survival window is the time between consecutive
passes. That's milliseconds to seconds under load. Whether
B[0] gets decremented 50us before or 50us after B[63] within
the same pass is below the resolution of anything usage_count
is trying to measure.

I don't think this is true, unfortunately. Sure, if you have a completely
uniform, IO intensive, workload it is, but that's not all there is. If you
have a bunch of connections that replace buffers at a low rate and a bunch of
connections that do so at a high rate, the batches "checked out" by the "low
rate" connections won't be processed soon. Thus the buffers in that batch
won't have their usagecount decremented and thus have a stronger "protection"
against replacement.

You can argue that may be OK, because it'd be unlikely that the next sweep
would assign the same buffers to an "low rate" backend again. But that'd be an
argument you'd have to make and validate.

You're right, and my "why it doesn't matter" section overstated things. The uniform-workload assumption was sloppy. Let me try again with the mixed case in mind.

The scenario I think you're describing: a low-rate backend claims B[0..63], finds a victim at B[5], and then doesn't call StrategyGetBuffer() again for a while -- maybe seconds. During that
time B[6..63] sit with their current usage_count, undecremented, while high-rate backends are sweeping the rest of the pool at full speed. Those 58 buffers get a free pass they wouldn't have gotten in the interleaved case.

I can bound the effect but not dismiss it. Each slow backend can hold at most 64 undecremented buffers. With 32GB shared_buffers (~4.2M buffers), 100 slow backends each holding a full batch means ~6,400 buffers delayed -- 0.15% of the pool. The delay lasts until the backend's next StrategyGetBuffer() call. So the question is whether 0.15% of buffers with temporarily stale usage_count produces a measurable eviction quality difference.

Two observations that bound it further:

1. In the current code, a backend only calls ClockSweepTick() when
it needs a victim. A low-rate backend barely moves the global
hand at all. Without batching, buffers at positions beyond the
current hand are also "undecremented" -- they just haven't been
reached yet. Batching changes *which* specific 64 buffers are
pending, not the total count of undecremented buffers in the
pool at any instant.

2. The buffers in a held batch are contiguous in buffer-ID space.
Since buffer-ID assignment to relation blocks is effectively
random (driven by eviction order), those 64 buffers are
scattered across relations. There's no systematic bias toward
protecting hot or cold data -- it's a random sample.

That said, "bounded and random" isn't the same as "zero." One mitigation that's simple to implement: abandon the remaining batch at transaction boundaries. Something like resetting MyBatchEnd = MyBatchPos in AtEOXact or equivalent, so a backend that goes idle between transactions doesn't hold a stale batch across an idle period. That limits the staleness window to the duration of a single transaction, which is when the backend is actively doing work and likely to consume the batch quickly anyway.

I'd like to measure the mixed-workload case directly. A benchmark with e.g. 50 backends doing heavy sequential scans and 50 doing single-row OLTP, and compare eviction hit rates with and without the patch. Would that be the kind of validation you'd want to see?

I'm somewhat doubtful that batching that's independent of contention and
independent of the usage rate will work out all that well. If you instead
went for a partitioned sweep architecture, with balancing between the
different partitions, you don't have that issue. And you have a building block
for more numa awareness etc.

I agree that partitioned sweep is architecturally more principled and gives you a foundation for deeper NUMA work. I'm not arguing that batching is a better long-term architecture.

The pragmatic case for batching is: it's ~30 lines, it addresses the identified bottleneck, and it doesn't foreclose on partitioned sweep later. If partitioned sweep lands, batching becomes redundant for its primary use case. If partitioned sweep lands incrementally -- which the open items in that thread suggest is the realistic path -- this gets a chunk of the multi-socket win into users' hands sooner.

One concrete difference: partitioned sweep needs a stealing mechanism for correctness when partitions are unevenly loaded. Batching avoids that because the "partitions" are ephemeral (one batch cycle) and sequential (global order preserved), so there's no long-lived imbalance to steal from. Whether that simplicity is worth the tradeoff you identified above is a judgment call, and I take your point that the building-block argument favors partitioning.

I'm also not attached to "batching instead of partitioning." If you think the right move is to focus effort on partitioned sweep, I'm happy to help with that. But if there's appetite for a smaller change that ships sooner, this is what I've got.

There is one subtle difference worth naming. When a backend finds a victim at B[5] of its batch, it returns with MyBatchEnd still sitting at B[63]. The next time that backend needs a victim it resumes at B[6], not at wherever the global hand now points. So the backend drains its batch over multiple StrategyGetBuffer() calls rather than all at once. Under heavy load, where batches are consumed in microseconds, this is invisible. Under light load, the implication is that some buffers can sit with slightly stale usage_count for longer than they would have before. But "light load" means "the sweep is barely moving and nothing wants to evict anyway" -- so the effect
doesn't show up where it would hurt.

As mentioned above, this assumes that the replacement rate is uniform between
backends, which I think is not uniformly true outside of benchmarks.

There's also a small positive side-effect: cache locality. The backend that
just touched BufferDescriptor[B[0]] has the adjacent descriptors warm in
L1/L2.

A BufferDesc is 64bytes. With common cacheline sizes and stuff like adjacent
cacheline prefetching you'll have *maybe* 2 consecutive BufferDescs in L1/L2.
Where it might help more is the TLB.

You're right, I overstated the L1/L2 argument. At 64 bytes per descriptor, adjacent cacheline prefetch gets you at most 2 consecutive descriptors, not 64. TLB is the more plausible benefit -- the batch walks a contiguous virtual address range, which should reduce TLB misses when the descriptor array spans multiple pages. I haven't tried to isolate this in perf and won't claim it until I have numbers.

Benchmarks
----------

Jim ran these; I'm still working on reproducing them locally and will post
independent numbers in a follow-up. All bare metal, Linux, huge pages
enabled throughout (more on that below), postmaster pinned to node 0 with
`numactl --cpunodebind=0` because otherwise stock TPS varied from 31K to 40K
depending on which node the postmaster happened to land on at launch --
worth flagging for anyone trying to reproduce.

That's an odd one that I think you need to investigate separately.

Agreed. I'll investigate and report separately. My working hypothesis is that it's related to where shared memory gets physically allocated relative to the postmaster's NUMA node, which then affects all child backends. That's interesting regardless of this patch.

Workload is pgbench scale 3000 (~45GB) with shared_buffers=32GB, so the
working set always spills and the sweep is hot.

Uhm, is this something worth optimizing substantially for? What you're
measuring here is basically the worst possible way of configuring a database,
with full double buffering and a lot of memory bandwidth dedicated to copying
buffers from one place to another. That's maybe a sane setup if you have a lot
of small databases that you can't configure individually, but that's not the
case when you run a reasonably large workload on a 384vCPU setup.

I think to be really convincing you'd have to do this with actual IO involved
somewhere.

Fair criticism. The pgbench setup was designed to isolate the clock sweep bottleneck by keeping everything in the OS page cache, but you're right that it doesn't represent how someone would actually run a database on a 384-vCPU box. In production you'd either size shared_buffers to hold the working set (no sweep pressure) or have real storage I/O (where I/O latency dilutes sweep contention).

The HammerDB TPC-C numbers (which involve I/O and realistic contention patterns) show flat-to-slightly-positive -- no regression, small win at higher concurrency. I think that's the more honest picture of what production looks like. And perhaps "flat to slightly-positive" delta might not be enough juice for the squeeze, especially this late in a cycle.

For the follow-up I'll run:

- Working set 2-3x shared_buffers on NVMe, so StrategyGetBuffer()
calls actually hit storage on some fraction of evictions.
- A mixed OLTP workload (not just pgbench -S) with varied access
patterns, to address the uniform-workload concern above.
- perf stat showing bus-cycles, cache-misses, and L3 contention
deltas, so the mechanism is visible independent of TPS.

I should have led with the TPC-C results and framed the pgbench numbers as "here's where the ceiling is under maximum sweep pressure" rather than presenting them as the headline result.

Relationship to Tomas's NUMA series
-----------------------------------

Tomas posted a multi-patch NUMA-awareness series in [1] covering buffer interleaving across nodes, partitioned freelists, partitioned clock sweep, PGPROC interleaving, and related pieces. I want to be careful here because I don't think we should frame this patch as competing with that work.

One thing I found striking as I re-read the thread: in the benchmarks Tomas
posted later in the series, *most of the benefit comes from partitioning the
clock sweep*, and the NUMA memory-placement layer on top sometimes runs
slower than partitioning alone. His own conclusion, quoted roughly: the
benefit mostly comes from just partitioning the clock sweep, and it's
largely independent of the NUMA stuff; the NUMA partitioning is often
slower.

That was partially because he measured on something that didn't really have
significant NUMA effects though...

Fair point. I shouldn't over-generalize from benchmarks run on hardware that wasn't exercising the NUMA dimension. Retracted.

That observation is the thing that makes me think batching is worth
considering on its own. It's going after the same bottleneck Tomas's
partitioning addresses, but:

- without splitting global eviction visibility (which is where
cross-partition stealing gets complicated),

You *are* doing that tho.

You're right. When a backend holds B[0..63], those buffers are effectively invisible to other backends for eviction consideration until the batch is consumed. That is a form of split visibility.

The difference from a permanent partition is that the split is short-lived (one batch consumption cycle, microseconds under load) and sequential (the next batch picks up where this one left off in the global order). There's no long-lived assignment of buffer ranges to backends, so the kind of structural imbalance that drives the need for cross-partition stealing doesn't arise. But I shouldn't have claimed "without splitting." The honest framing is: "with much more limited
and transient splitting."

- without requiring NUMA-aware buffer placement (which has huge
page alignment, descriptor-partition-mid-page, and resize
complications that are still being worked out in that thread),

You can do the partitioned clock sweep without *any* of that.

Also correct. The complications I listed are from Tomas's patches 0001 and 0006 (memory interleaving and NUMA-aware buffer-to-node mapping), not from the clock-sweep partitioning patches 0002-0005. Partitioned clock sweep alone doesn't require NUMA-aware buffer placement. I
conflated the two; apologies.

Greetings,

Andres Freund

To summarize the open items I'm taking away:

1. Mixed-workload benchmark (high-rate + low-rate backends) to
measure eviction quality impact of held batches.
2. I/O-inclusive benchmarks on NVMe with working set > shared_buffers.
3. Investigate the postmaster NUMA placement variance separately.
4. Consider batch-abandonment at transaction boundaries as a
mitigation for the staleness concern.
5. perf stat data showing the mechanism (bus-cycles, cache-misses).

I'll post results as I have them. I greatly appreciate your time and thoughtful review.

best.

-greg

#5Greg Burd
greg@burd.me
In reply to: Greg Burd (#4)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

Hello again,

Removing the freelist [1]Reconsidering the freelist /messages/by-id/f0e3c02e-e217-4f04-8dab-1e7e80a228c0@burd.me turned out to be a good idea, so why not remove
and simplify more?

Included is a three-patch series (v3, on 2e6578292a9 cf-7000) that
replaces the 0..5 usage_count clock sweep with a cooling-stage clock,
and because this evictor algorithm is scan resistance 0003 removes
the BufferAccessStrategy ring buffers entirely. The net is slightly
faster (TPS), hotter buffers (fewer misses) and a lot less code to
maintain (no special case for scan resistant work) going forward.

0001 Batch the clock sweep to reduce nextVictimBuffer atomic contention
0002 Replace the usage_count clock sweep with a cooling-stage evictor
0003 Remove BufferAccessStrategy; scan resistance is now intrinsic

Net it is +510/-1642 over 83 files; almost all of the deletion is 0003.

On a 6-NUMA-node r8i.metal-96xl under continuous eviction, this change is
a consistent +4–5% on read-heavy pgbench with equal-or-better hit ratios.
The same test on a 2-node box is flat, which is the tell that the win
is the cross-socket clock-hand contention the batched sweep removes. Under
real storage IO (working set > RAM on local NVMe), the scan-resistance
path is +1.6–3.9% with measurably fewer heap reads.

I'll put the design decisions and the benchmark methodology on the table
first, then talk through the results because if the model is wrong on
principle, or the benchmarks are measuring the wrong thing, I would much
rather hear it now.

Contention arises on high core-count systems from the of cost advancing
nextVictimBuffer in StrategyGetBuffer() via a fetch-add of 1 per tick.
This contention is exacerbated on a multi-socket box. That one cache
line bounces over the interconnect on every eviction; under pressure
with hundreds of backends it is the dominant cost of the sweep. This is
why we're investing time on NUMA optimizations.

== What each patch does, and why ==

0001 -- Batch the clock sweep.

This is the setup/baseline commit and is an incremental improvement
over the existing batched approach, but it is not the final goal and
should not be committed without 0002.

Each backend will claim a run of consecutive buffer IDs with a single
fetch-add and iterate them privately. Global sweep order is preserved
-- each buffer is visited exactly once per pass -- only the intra-pass
ordering of visits changes, which the algorithm does not depend on.
The atomic fires ~1/batch as often.

The batch is one cache line's worth of hand values (PG_CACHE_LINE_SIZE
/ sizeof(uint32)), capped at NBuffers. It is gated on multi-node NUMA
(pg_numa_get_max_node() >= 1); on a single socket the batch is 1 and
the path is byte-identical to today. This is essentially Jim's patch;
the only substantive change from his v1/v2 is deriving the batch size
from the cache-line size rather than a fixed 64/tiered constant.

0002 -- Replace usage_count with a cooling-stage evictor.

This is the heart of the series and where I most want the design
attacked.

A buffer is HOT (recently used) or COOL (an eviction candidate);
"pinned" is the existing refcount. There is no per-buffer 0..5
counter. A demand-loaded page is admitted COOL (probationary); a
second access promotes it COOL->HOT. So a page touched once -- as is
the case with a sequential scan -- fills and drains the COOL stage and
is evicted from it without displacing the HOT working set. This is
the LeanStore / 2Q-A1 idea, and it is the whole reason 0003 can exist:
scan resistance stops being a ring-buffer bolt-on and becomes a
property of the replacement algorithm.

The 4-bit usage_count field is reinterpreted in place -- bit 0
HOT/COOL, bit 1 a reference bit, the top 2 bits unused -- so the 64-bit
buffer-state layout, its refcount / flag / lock offsets. The only
change to the StaticAsserts is a new one asserting the field is at
least 2 bits wide, since we now use two of its bits. The eviction
claim (COOL, unpinned -> pinned) stays a CAS so a racing PinBuffer
always wins; promotion and demotion are single-bit transitions.

The one non-obvious decision, which I got wrong first and want to flag
loudly: *Who* demotes HOT->COOL. My first cut was "prefer-COOL": the
foreground sweep skips HOT buffers hunting for an already-COOL victim
and only cools HOT buffers once a full pass finds none. That
collapses under an ordinary OLTP workload where the working set is
larger than shared_buffers (no scan at all). Every access promotes
its buffer to HOT, so there are almost no COOL buffers, the "cool a
full pass" fallback fires on nearly every allocation, and each victim
search becomes a ~2x full-pool scan of scattered BufferDescs. I
measured 3-17x throughput loss vs stock -- a cliff.

HOT->COOL demotion is done during the background writer's existing LRU
scan, which already runs ahead of the clock hand. It demotes just
enough HOT buffers to keep a supply of COOL victims (bounded by the
predicted next-cycle allocation, so it does not cool the whole pool),
under the buffer header lock it already holds. A single reference bit
gives a recently-accessed buffer one reprieve before it is cooled,
which keeps the genuinely-hot set out of the COOL stage under scan
pressure. With that, the foreground finds a victim in a single pass
and the cliff is gone (data below).

I chose prefer-COOL-plus-bgwriter-precooling because it can protect a
hot buffer slightly longer (the pre-cooler, tuned to a budget, decides
when to demote rather than demoting on contact), which should help hit
ratio on a stable hot set -- IF the pre-cooler keeps up. When it lags
(bgwriter off or behind), the foreground force_cool is still there as
a correctness fallback, but it is the expensive path.

0003 -- Remove BufferAccessStrategy.

With scan resistance intrinsic, the BAS_BULKREAD/BULKWRITE/VACUUM
rings are dead weight, so this removes them end to end: the type and
enum, the ring machinery in freelist.c, the strategy parameter
threaded through ReadBufferExtended / the ExtendBufferedRel* family /
read_stream / every scan/vacuum/analyze/index-AM caller, the strategy
fields on the scan and bulk-insert descriptors, and
_hash_getbuf_with_strategy. pg_stat_io's per-strategy IO contexts
collapse to normal/init (IOOP_REUSE only ever happened while recycling
a ring buffer, so it is gone), and the vacuum_buffer_usage_limit GUC /
VACUUM (BUFFER_USAGE_LIMIT ...) option / vacuumdb --buffer-usage-limit
go with it.

This is the patch most likely to be contentious for reasons unrelated
to the sweep: it removes a user-visible GUC and changes pg_stat_io's
shape. I have kept it as its own commit precisely so 0001+0002 can be
judged on the algorithm without swallowing the removal. If the
consensus is that the cooling model is fine but the ring machinery
should stay for other reasons (BUFFER_USAGE_LIMIT as an operator
control, say), 0003 can simply be dropped and 0002 still stands -- the
rings just become redundant rather than removed. I would like to know
if that is where people land.

== Benchmarks ==

I want to be careful here, because Andres's central criticism of the
original batched-sweep numbers [2]Re: Restructured Shared Buffer Hash Table /messages/by-id/dbbd1998-19ff-4ac2-b4b1-a39f4ec1b0f5@iki.fi was that they measured the wrong
thing -- an all-in-page-cache config where the only bottleneck left is
the atomic, which is not how anyone runs a 384-vCPU box. That criticism
is correct and I have tried to design around it, but I have almost
certainly not fully escaped it, so the methodology is laid out below in
enough detail to shoot at.

I refer to the COOL/HOT approach (the changes in this patch set) as
"bcs", I've forgotten what that stands for... "buffer cache solution?"
I really don't remember (ha!).

Hardware / method (both instances bare-metal, Amazon Linux 2023):

- m6i.metal -- 128 vCPU, 2 sockets, 2 NUMA nodes (distances 10/20), 503GB
- r8i.metal-96xl -- 384 vCPU, 2 sockets, 6 NUMA nodes via SNC3, 3TB

Builds: --buildtype=debugoptimized (-O2), cassert off, --with-libnuma,
both branches from the same tree. Postmaster pinned with numactl
--cpunodebind=0 --membind=0 (without it stock TPS varied ~30% by launch
node -- worth flagging for anyone reproducing). pgbench dataset loaded
once per build into its own datadir.

The regime I settled on: a dataset that fits fully in OS page cache but
exceeds shared_buffers, warmed before each cell, caches NOT dropped
between cells. The point is a sweep that runs continuously with
sub-millisecond read latency and NO storage IO in the critical path --
so what I am measuring is the eviction machinery itself, not the disk
(as best as I can tell).

I sweep the ratio (working-set / shared_buffers) from 0.8 (fits, no
eviction, a control) up to 8x by varying shared_buffers against a fixed
63GB dataset.

3 iterations per cell, medians reported, 256 clients on m6i / 384 on r8i.

Two workloads: uniform pgbench -S (the pure eviction-churn case, and the
worst case for the cooling model -- no hot set to protect), and
"hotscan" (a Zipfian hot set plus a handful of clients running large
range scans -- the scan-resistance case).

I want the regime itself critiqued. It deliberately removes storage IO
to expose the sweep; the flip side is it is not a production
configuration, and the read-path win (fewer misses under scan
resistance) shows up as read count, not TPS, because a "miss" here is an
OS-cache memcpy, not a device read.

r8i.metal-96xl, uniform pgbench -S, 384 clients, TPS (median of 3):

ratio SB(GB) stock bcs delta stock/bcs hit%
0.8 ~400 1,623,536 1,692,090 +4.2% 99.2 / 99.9
1.25 ~320 1,443,343 1,519,290 +5.3% 94.8 / 95.2
1.5 ~266 1,344,051 1,417,462 +5.5% 91.9 / 92.1
2 ~200 1,293,871 1,358,550 +5.0% 87.9 / 87.8
4 ~100 1,194,405 1,240,334 +3.8% 77.1 / 78.5
8 ~50 1,090,873 1,140,839 +4.6% 69.7 / 70.6

Consistent +4-5% across the eviction range, growing with pressure, with
equal-or-better hit ratio (better at 4x/8x). bcs also showed lower
cache-miss rate in perf stat (~28-35% vs ~31-37%), which is the batched
sweep's reduced cross-node line bouncing showing through.

r8i, hotscan (Zipfian hot set + range scanners), TPS / hit% / heap reads:

ratio stock TPS bcs TPS stock hit bcs hit stock reads bcs reads
1.25 1,833,675 1,871,289 99.43 99.57 7,309,457 5,503,927
1.5 1,876,846 1,942,877 99.35 99.42 8,335,144 7,565,667
2 1,868,591 1,853,547 99.12 99.15 11,173,831 10,987,371

The scan-resistance signal: at 1.25-1.5x, bcs holds a higher hit ratio
and does up to 25% fewer heap reads (24.7% at 1.25x, ~9% at 1.5x) -- it
is keeping the hot set resident through the scans where stock lets them
flush it. Muted in TPS only because everything is in OS cache (a miss
is cheap); on real storage this read reduction is where the win would
land. At 2x it washes out (enough pressure that both evict heavily).

m6i.metal (2 nodes), uniform, 256 clients -- the smaller box, for contrast:
essentially parity, bcs -2% to +1% across ratios. The 2-node box barely
exercises the atomic, so 0001's contention win does not appear; that it does
not regress is the result that matters here.

Huge pages on vs off (r8i, uniform, medians): I ran this because the
original thread flagged it as uncharacterized. bcs won by +3-7% both
ways, no regression without huge pages -- the win is from cutting the
frequency of atomic ops on the counter line, which does not depend on
where the descriptors physically live. (This is why the batching gate
is NUMA-only and not also huge-pages-gated.)

The "prefer-COOL cliff" I mentioned under 0002, so the failure mode is
on the record: BEFORE moving cooling into the bgwriter, the m6i uniform
run at 256 clients was bcs 274K vs stock 840K at ratio 2 (-67%), and
42.8K vs 762K at 8x (-94%), with cache-miss rate exploding to ~40%.
That is the shape of getting the demotion policy wrong; the r8i +5%
table above is after the fix.

Reproduction: the whole harness (instance launch, OS tuning, per-build
load, the ratio sweep, perf stat capture) is scripted; I will attach it
as a DO-NOT-MERGE commit / put it in the CF entry so the methodology can
be reproduced and picked apart rather than taken on faith. Raw per-run
CSVs and perf output likewise.

A Real IO (working set > RAM, evictions hitting storage) Benchmark

This is the regime Andres asked for, and the one I flagged earlier as not yet
done cleanly. The earlier attempt was EBS-latency-bound; this one uses local
NVMe so eviction reads hit real storage at ~microsecond, not ~15ms, latency --
during the run the array sat at 100% utilization and ~145K read IOPS, so the
eviction path is genuinely storage-bound, not cache-served.

m6id.metal -- 128 vCPU, 2 sockets, 2 NUMA nodes, 503GB, 4x1.9TB local NVMe in
RAID0. Dataset ~700GB (pgbench scale 47000), i.e. LARGER than RAM, so the
working set cannot sit in the OS page cache. shared_buffers is a small window
over it -- 63GB (11x) and 31GB (22x) -- caches dropped per cell, 256 clients, 3
iterations, medians. Same builds/method as the in-cache runs otherwise.

hotscan (Zipfian hot set + range scanners), median of 3:

ratio SB(GB) stock TPS bcs TPS dTPS stock/bcs hit reads d
11 63 877,357 911,363 +3.9% 96.30 / 96.43 -2.2%
22 31 873,880 888,010 +1.6% 94.02 / 94.22 -1.5%

This is the result the in-cache runs could only hint at: under real storage IO
the scan-resistance read reduction converts to throughput. The bcs approach
keeps a higher hit ratio and does 1.5-2.2% fewer heap reads, and here -- unlike
in cache, where a miss is a cheap memcpy -- a read it avoids is an NVMe round
trip, so the read reduction shows up as +1.6-3.9% TPS.

uniform pgbench -S (pure eviction churn, no hot set to protect), median of 3:

ratio SB(GB) stock TPS bcs TPS dTPS stock/bcs hit
11 63 605,656 616,247 +1.7% 67.0 / 67.3
22 31 605,517 611,312 +1.0% 63.5 / 63.6

Hit ratio here is 63-67% -- a third of accesses miss and hit NVMe (190M-220M
evictions per run), so this is deep, genuinely storage-bound churn. bcs is
+1-1.7%, i.e. flat-to-slightly-positive, which is the honest production picture:
with no hot set to protect, scan resistance has nothing to do, and the win is
just the batched sweep's reduced contention showing faintly through the IO wait.
Notably bcs does not regress even when its policy has no advantage to exploit.

== Side note for the curious... ==

Separately, Dhruv Aron has proposed restructuring the shared-buffer lookup table
[2]: Re: Restructured Shared Buffer Hash Table /messages/by-id/dbbd1998-19ff-4ac2-b4b1-a39f4ec1b0f5@iki.fi
other hot cost on a buffer miss — resolving a page to its buffer — where this
series attacks the eviction that a miss triggers. They touch buf_table.c and a
lock-ordering change in InvalidateBuffer(); this series touches freelist.c and
the per-buffer replacement state, and removes BufferAccessStrategy. The two are
complementary and should compound on the miss path; the only overlap is
InvalidateBuffer()/GetVictimBuffer(), where their extended buffer-header-lock
hold and this series' CAS-claim + bgwriter pre-cooling both take that lock, and
would want reconciling if both land. I have not benchmarked them together (yet).

== What I have not done, honestly ==

- Hardening the foreground force_cool fallback to be cheap when it
fires, rather than relying on the bgwriter pre-cooler never lagging.

- Anything on single-socket beyond "does not regress"; the design is
not trying to help there.

== The ask ==

1. 0002's demotion policy: is prefer-COOL + bgwriter pre-cooling the
right call, or is the other team's cool-in-place the more robust
default given it has no cliff and no background-process dependency?
This is the decision everything else hangs on.

2. Is admitting demand-loaded pages COOL (probationary,
promote-on-second- touch) an acceptable basis for scan resistance
in the core buffer manager, i.e. is it OK to make scan resistance
an algorithm property and retire the strategy rings (0003)? Or
should the rings stay and 0002 ride alongside them?

3. The benchmark methodology: where is the in-OS-cache regime
misleading, and what would you want measured instead? I am most
worried I am flattering the sweep by removing the IO that would
otherwise hide it.

4. Reinterpreting the usage_count field as {HOT/COOL, ref} bits and
collapsing pg_stat_io's contexts -- acceptable, or is there a
cleaner representation the project would want before this is worth
pursuing?

I have measured that the 0..5 count is overhead and provides no
meaningful signal at all, that a HOT/COLD approach provides a simpler
more stable and better performing eviction model for the buffer pool.
If you dispute that, let's dig in and compare notes. :)

I would be remiss if I didn't point out the thread [3]Adding basic NUMA awareness (Tomas Vondra) /messages/by-id/099b9433-2855-4f1b-b421-d078a5d82017@vondra.me by Tomas et. al.,
whose NUMA investigation targets the same bottlenecks, and inspired the
work that led to this set of ideas.

Thanks for reading this far. I look forward to the critique.

best.

-greg

[1]: Reconsidering the freelist /messages/by-id/f0e3c02e-e217-4f04-8dab-1e7e80a228c0@burd.me
/messages/by-id/f0e3c02e-e217-4f04-8dab-1e7e80a228c0@burd.me
[2]: Re: Restructured Shared Buffer Hash Table /messages/by-id/dbbd1998-19ff-4ac2-b4b1-a39f4ec1b0f5@iki.fi
/messages/by-id/dbbd1998-19ff-4ac2-b4b1-a39f4ec1b0f5@iki.fi
[3]: Adding basic NUMA awareness (Tomas Vondra) /messages/by-id/099b9433-2855-4f1b-b421-d078a5d82017@vondra.me
/messages/by-id/099b9433-2855-4f1b-b421-d078a5d82017@vondra.me

Attachments:

v3-0002-Replace-the-usage_count-clock-sweep-with-a-coolin.patchtext/x-patch; name="=?UTF-8?Q?v3-0002-Replace-the-usage=5Fcount-clock-sweep-with-a-coolin.pa?= =?UTF-8?Q?tch?="Download+229-86
v3-0003-Remove-BufferAccessStrategy-scan-resistance-is-no.patchtext/x-patch; name="=?UTF-8?Q?v3-0003-Remove-BufferAccessStrategy-scan-resistance-is-no.patc?= =?UTF-8?Q?h?="Download+252-1492
v3-0001-Batch-the-clock-sweep-to-reduce-nextVictimBuffer-.patchtext/x-patch; name="=?UTF-8?Q?v3-0001-Batch-the-clock-sweep-to-reduce-nextVictimBuffer-.patc?= =?UTF-8?Q?h?="Download+91-44
#6Greg Burd
greg@burd.me
In reply to: Greg Burd (#5)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

On Fri, Jul 10, 2026, at 10:00 AM, Greg Burd wrote:

Hello again,

Removing the freelist [1] turned out to be a good idea, so why not remove
and simplify more?

Included is a three-patch series (v3, on 2e6578292a9 cf-7000) that
replaces the 0..5 usage_count clock sweep with a cooling-stage clock,
and because this evictor algorithm is scan resistance 0003 removes
the BufferAccessStrategy ring buffers entirely. The net is slightly
faster (TPS), hotter buffers (fewer misses) and a lot less code to
maintain (no special case for scan resistant work) going forward.

0001 Batch the clock sweep to reduce nextVictimBuffer atomic contention
0002 Replace the usage_count clock sweep with a cooling-stage evictor
0003 Remove BufferAccessStrategy; scan resistance is now intrinsic

Net it is +510/-1642 over 83 files; almost all of the deletion is 0003.

On a 6-NUMA-node r8i.metal-96xl under continuous eviction, this change is
a consistent +4–5% on read-heavy pgbench with equal-or-better hit ratios.
The same test on a 2-node box is flat, which is the tell that the win
is the cross-socket clock-hand contention the batched sweep removes. Under
real storage IO (working set > RAM on local NVMe), the scan-resistance
path is +1.6–3.9% with measurably fewer heap reads.

I'll put the design decisions and the benchmark methodology on the table
first, then talk through the results because if the model is wrong on
principle, or the benchmarks are measuring the wrong thing, I would much
rather hear it now.

Contention arises on high core-count systems from the of cost advancing
nextVictimBuffer in StrategyGetBuffer() via a fetch-add of 1 per tick.
This contention is exacerbated on a multi-socket box. That one cache
line bounces over the interconnect on every eviction; under pressure
with hundreds of backends it is the dominant cost of the sweep. This is
why we're investing time on NUMA optimizations.

== What each patch does, and why ==

0001 -- Batch the clock sweep.

This is the setup/baseline commit and is an incremental improvement
over the existing batched approach, but it is not the final goal and
should not be committed without 0002.

Each backend will claim a run of consecutive buffer IDs with a single
fetch-add and iterate them privately. Global sweep order is preserved
-- each buffer is visited exactly once per pass -- only the intra-pass
ordering of visits changes, which the algorithm does not depend on.
The atomic fires ~1/batch as often.

The batch is one cache line's worth of hand values (PG_CACHE_LINE_SIZE
/ sizeof(uint32)), capped at NBuffers. It is gated on multi-node NUMA
(pg_numa_get_max_node() >= 1); on a single socket the batch is 1 and
the path is byte-identical to today. This is essentially Jim's patch;
the only substantive change from his v1/v2 is deriving the batch size
from the cache-line size rather than a fixed 64/tiered constant.

0002 -- Replace usage_count with a cooling-stage evictor.

This is the heart of the series and where I most want the design
attacked.

A buffer is HOT (recently used) or COOL (an eviction candidate);
"pinned" is the existing refcount. There is no per-buffer 0..5
counter. A demand-loaded page is admitted COOL (probationary); a
second access promotes it COOL->HOT. So a page touched once -- as is
the case with a sequential scan -- fills and drains the COOL stage and
is evicted from it without displacing the HOT working set. This is
the LeanStore / 2Q-A1 idea, and it is the whole reason 0003 can exist:
scan resistance stops being a ring-buffer bolt-on and becomes a
property of the replacement algorithm.

The 4-bit usage_count field is reinterpreted in place -- bit 0
HOT/COOL, bit 1 a reference bit, the top 2 bits unused -- so the 64-bit
buffer-state layout, its refcount / flag / lock offsets. The only
change to the StaticAsserts is a new one asserting the field is at
least 2 bits wide, since we now use two of its bits. The eviction
claim (COOL, unpinned -> pinned) stays a CAS so a racing PinBuffer
always wins; promotion and demotion are single-bit transitions.

The one non-obvious decision, which I got wrong first and want to flag
loudly: *Who* demotes HOT->COOL. My first cut was "prefer-COOL": the
foreground sweep skips HOT buffers hunting for an already-COOL victim
and only cools HOT buffers once a full pass finds none. That
collapses under an ordinary OLTP workload where the working set is
larger than shared_buffers (no scan at all). Every access promotes
its buffer to HOT, so there are almost no COOL buffers, the "cool a
full pass" fallback fires on nearly every allocation, and each victim
search becomes a ~2x full-pool scan of scattered BufferDescs. I
measured 3-17x throughput loss vs stock -- a cliff.

HOT->COOL demotion is done during the background writer's existing LRU
scan, which already runs ahead of the clock hand. It demotes just
enough HOT buffers to keep a supply of COOL victims (bounded by the
predicted next-cycle allocation, so it does not cool the whole pool),
under the buffer header lock it already holds. A single reference bit
gives a recently-accessed buffer one reprieve before it is cooled,
which keeps the genuinely-hot set out of the COOL stage under scan
pressure. With that, the foreground finds a victim in a single pass
and the cliff is gone (data below).

I chose prefer-COOL-plus-bgwriter-precooling because it can protect a
hot buffer slightly longer (the pre-cooler, tuned to a budget, decides
when to demote rather than demoting on contact), which should help hit
ratio on a stable hot set -- IF the pre-cooler keeps up. When it lags
(bgwriter off or behind), the foreground force_cool is still there as
a correctness fallback, but it is the expensive path.

0003 -- Remove BufferAccessStrategy.

With scan resistance intrinsic, the BAS_BULKREAD/BULKWRITE/VACUUM
rings are dead weight, so this removes them end to end: the type and
enum, the ring machinery in freelist.c, the strategy parameter
threaded through ReadBufferExtended / the ExtendBufferedRel* family /
read_stream / every scan/vacuum/analyze/index-AM caller, the strategy
fields on the scan and bulk-insert descriptors, and
_hash_getbuf_with_strategy. pg_stat_io's per-strategy IO contexts
collapse to normal/init (IOOP_REUSE only ever happened while recycling
a ring buffer, so it is gone), and the vacuum_buffer_usage_limit GUC /
VACUUM (BUFFER_USAGE_LIMIT ...) option / vacuumdb --buffer-usage-limit
go with it.

This is the patch most likely to be contentious for reasons unrelated
to the sweep: it removes a user-visible GUC and changes pg_stat_io's
shape. I have kept it as its own commit precisely so 0001+0002 can be
judged on the algorithm without swallowing the removal. If the
consensus is that the cooling model is fine but the ring machinery
should stay for other reasons (BUFFER_USAGE_LIMIT as an operator
control, say), 0003 can simply be dropped and 0002 still stands -- the
rings just become redundant rather than removed. I would like to know
if that is where people land.

== Benchmarks ==

I want to be careful here, because Andres's central criticism of the
original batched-sweep numbers [2] was that they measured the wrong
thing -- an all-in-page-cache config where the only bottleneck left is
the atomic, which is not how anyone runs a 384-vCPU box. That criticism
is correct and I have tried to design around it, but I have almost
certainly not fully escaped it, so the methodology is laid out below in
enough detail to shoot at.

I refer to the COOL/HOT approach (the changes in this patch set) as
"bcs", I've forgotten what that stands for... "buffer cache solution?"
I really don't remember (ha!).

Hardware / method (both instances bare-metal, Amazon Linux 2023):

- m6i.metal -- 128 vCPU, 2 sockets, 2 NUMA nodes (distances 10/20), 503GB
- r8i.metal-96xl -- 384 vCPU, 2 sockets, 6 NUMA nodes via SNC3, 3TB

Builds: --buildtype=debugoptimized (-O2), cassert off, --with-libnuma,
both branches from the same tree. Postmaster pinned with numactl
--cpunodebind=0 --membind=0 (without it stock TPS varied ~30% by launch
node -- worth flagging for anyone reproducing). pgbench dataset loaded
once per build into its own datadir.

The regime I settled on: a dataset that fits fully in OS page cache but
exceeds shared_buffers, warmed before each cell, caches NOT dropped
between cells. The point is a sweep that runs continuously with
sub-millisecond read latency and NO storage IO in the critical path --
so what I am measuring is the eviction machinery itself, not the disk
(as best as I can tell).

I sweep the ratio (working-set / shared_buffers) from 0.8 (fits, no
eviction, a control) up to 8x by varying shared_buffers against a fixed
63GB dataset.

3 iterations per cell, medians reported, 256 clients on m6i / 384 on r8i.

Two workloads: uniform pgbench -S (the pure eviction-churn case, and the
worst case for the cooling model -- no hot set to protect), and
"hotscan" (a Zipfian hot set plus a handful of clients running large
range scans -- the scan-resistance case).

I want the regime itself critiqued. It deliberately removes storage IO
to expose the sweep; the flip side is it is not a production
configuration, and the read-path win (fewer misses under scan
resistance) shows up as read count, not TPS, because a "miss" here is an
OS-cache memcpy, not a device read.

r8i.metal-96xl, uniform pgbench -S, 384 clients, TPS (median of 3):

ratio SB(GB) stock bcs delta stock/bcs hit%
0.8 ~400 1,623,536 1,692,090 +4.2% 99.2 / 99.9
1.25 ~320 1,443,343 1,519,290 +5.3% 94.8 / 95.2
1.5 ~266 1,344,051 1,417,462 +5.5% 91.9 / 92.1
2 ~200 1,293,871 1,358,550 +5.0% 87.9 / 87.8
4 ~100 1,194,405 1,240,334 +3.8% 77.1 / 78.5
8 ~50 1,090,873 1,140,839 +4.6% 69.7 / 70.6

Consistent +4-5% across the eviction range, growing with pressure, with
equal-or-better hit ratio (better at 4x/8x). bcs also showed lower
cache-miss rate in perf stat (~28-35% vs ~31-37%), which is the batched
sweep's reduced cross-node line bouncing showing through.

r8i, hotscan (Zipfian hot set + range scanners), TPS / hit% / heap reads:

ratio stock TPS bcs TPS stock hit bcs hit stock reads bcs reads
1.25 1,833,675 1,871,289 99.43 99.57 7,309,457 5,503,927
1.5 1,876,846 1,942,877 99.35 99.42 8,335,144 7,565,667
2 1,868,591 1,853,547 99.12 99.15 11,173,831 10,987,371

The scan-resistance signal: at 1.25-1.5x, bcs holds a higher hit ratio
and does up to 25% fewer heap reads (24.7% at 1.25x, ~9% at 1.5x) -- it
is keeping the hot set resident through the scans where stock lets them
flush it. Muted in TPS only because everything is in OS cache (a miss
is cheap); on real storage this read reduction is where the win would
land. At 2x it washes out (enough pressure that both evict heavily).

m6i.metal (2 nodes), uniform, 256 clients -- the smaller box, for contrast:
essentially parity, bcs -2% to +1% across ratios. The 2-node box barely
exercises the atomic, so 0001's contention win does not appear; that it does
not regress is the result that matters here.

Huge pages on vs off (r8i, uniform, medians): I ran this because the
original thread flagged it as uncharacterized. bcs won by +3-7% both
ways, no regression without huge pages -- the win is from cutting the
frequency of atomic ops on the counter line, which does not depend on
where the descriptors physically live. (This is why the batching gate
is NUMA-only and not also huge-pages-gated.)

The "prefer-COOL cliff" I mentioned under 0002, so the failure mode is
on the record: BEFORE moving cooling into the bgwriter, the m6i uniform
run at 256 clients was bcs 274K vs stock 840K at ratio 2 (-67%), and
42.8K vs 762K at 8x (-94%), with cache-miss rate exploding to ~40%.
That is the shape of getting the demotion policy wrong; the r8i +5%
table above is after the fix.

Reproduction: the whole harness (instance launch, OS tuning, per-build
load, the ratio sweep, perf stat capture) is scripted; I will attach it
as a DO-NOT-MERGE commit / put it in the CF entry so the methodology can
be reproduced and picked apart rather than taken on faith. Raw per-run
CSVs and perf output likewise.

A Real IO (working set > RAM, evictions hitting storage) Benchmark

This is the regime Andres asked for, and the one I flagged earlier as not yet
done cleanly. The earlier attempt was EBS-latency-bound; this one uses local
NVMe so eviction reads hit real storage at ~microsecond, not ~15ms, latency --
during the run the array sat at 100% utilization and ~145K read IOPS, so the
eviction path is genuinely storage-bound, not cache-served.

m6id.metal -- 128 vCPU, 2 sockets, 2 NUMA nodes, 503GB, 4x1.9TB local NVMe in
RAID0. Dataset ~700GB (pgbench scale 47000), i.e. LARGER than RAM, so the
working set cannot sit in the OS page cache. shared_buffers is a small window
over it -- 63GB (11x) and 31GB (22x) -- caches dropped per cell, 256 clients, 3
iterations, medians. Same builds/method as the in-cache runs otherwise.

hotscan (Zipfian hot set + range scanners), median of 3:

ratio SB(GB) stock TPS bcs TPS dTPS stock/bcs hit reads d
11 63 877,357 911,363 +3.9% 96.30 / 96.43 -2.2%
22 31 873,880 888,010 +1.6% 94.02 / 94.22 -1.5%

This is the result the in-cache runs could only hint at: under real storage IO
the scan-resistance read reduction converts to throughput. The bcs approach
keeps a higher hit ratio and does 1.5-2.2% fewer heap reads, and here -- unlike
in cache, where a miss is a cheap memcpy -- a read it avoids is an NVMe round
trip, so the read reduction shows up as +1.6-3.9% TPS.

uniform pgbench -S (pure eviction churn, no hot set to protect), median of 3:

ratio SB(GB) stock TPS bcs TPS dTPS stock/bcs hit
11 63 605,656 616,247 +1.7% 67.0 / 67.3
22 31 605,517 611,312 +1.0% 63.5 / 63.6

Hit ratio here is 63-67% -- a third of accesses miss and hit NVMe (190M-220M
evictions per run), so this is deep, genuinely storage-bound churn. bcs is
+1-1.7%, i.e. flat-to-slightly-positive, which is the honest production picture:
with no hot set to protect, scan resistance has nothing to do, and the win is
just the batched sweep's reduced contention showing faintly through the IO wait.
Notably bcs does not regress even when its policy has no advantage to exploit.

== Side note for the curious... ==

Separately, Dhruv Aron has proposed restructuring the shared-buffer lookup table
[2], replacing dynahash with a flat two-array structure. That attacks the
other hot cost on a buffer miss — resolving a page to its buffer — where this
series attacks the eviction that a miss triggers. They touch buf_table.c and a
lock-ordering change in InvalidateBuffer(); this series touches freelist.c and
the per-buffer replacement state, and removes BufferAccessStrategy. The two are
complementary and should compound on the miss path; the only overlap is
InvalidateBuffer()/GetVictimBuffer(), where their extended buffer-header-lock
hold and this series' CAS-claim + bgwriter pre-cooling both take that lock, and
would want reconciling if both land. I have not benchmarked them together (yet).

== What I have not done, honestly ==

- Hardening the foreground force_cool fallback to be cheap when it
fires, rather than relying on the bgwriter pre-cooler never lagging.

- Anything on single-socket beyond "does not regress"; the design is
not trying to help there.

== The ask ==

1. 0002's demotion policy: is prefer-COOL + bgwriter pre-cooling the
right call, or is the other team's cool-in-place the more robust
default given it has no cliff and no background-process dependency?
This is the decision everything else hangs on.

2. Is admitting demand-loaded pages COOL (probationary,
promote-on-second- touch) an acceptable basis for scan resistance
in the core buffer manager, i.e. is it OK to make scan resistance
an algorithm property and retire the strategy rings (0003)? Or
should the rings stay and 0002 ride alongside them?

3. The benchmark methodology: where is the in-OS-cache regime
misleading, and what would you want measured instead? I am most
worried I am flattering the sweep by removing the IO that would
otherwise hide it.

4. Reinterpreting the usage_count field as {HOT/COOL, ref} bits and
collapsing pg_stat_io's contexts -- acceptable, or is there a
cleaner representation the project would want before this is worth
pursuing?

I have measured that the 0..5 count is overhead and provides no
meaningful signal at all, that a HOT/COLD approach provides a simpler
more stable and better performing eviction model for the buffer pool.
If you dispute that, let's dig in and compare notes. :)

I would be remiss if I didn't point out the thread [3] by Tomas et. al.,
whose NUMA investigation targets the same bottlenecks, and inspired the
work that led to this set of ideas.

Thanks for reading this far. I look forward to the critique.

best.

-greg

[1] Reconsidering the freelist

/messages/by-id/f0e3c02e-e217-4f04-8dab-1e7e80a228c0@burd.me
[2] Re: Restructured Shared Buffer Hash Table

/messages/by-id/dbbd1998-19ff-4ac2-b4b1-a39f4ec1b0f5@iki.fi
[3] Adding basic NUMA awareness (Tomas Vondra)

/messages/by-id/099b9433-2855-4f1b-b421-d078a5d82017@vondra.me
Attachments:
* v3-0001-Batch-the-clock-sweep-to-reduce-nextVictimBuffer-.patch
* v3-0002-Replace-the-usage_count-clock-sweep-with-a-coolin.patch
* v3-0003-Remove-BufferAccessStrategy-scan-resistance-is-no.patch

Rebased v4 onto c71d43025d7.

-greg

Attachments:

v4-0001-Batch-the-clock-sweep-to-reduce-nextVictimBuffer-.patchtext/x-patch; name="=?UTF-8?Q?v4-0001-Batch-the-clock-sweep-to-reduce-nextVictimBuffer-.patc?= =?UTF-8?Q?h?="Download+91-44
v4-0002-Replace-the-usage_count-clock-sweep-with-a-coolin.patchtext/x-patch; name="=?UTF-8?Q?v4-0002-Replace-the-usage=5Fcount-clock-sweep-with-a-coolin.pa?= =?UTF-8?Q?tch?="Download+229-86
v4-0003-Remove-BufferAccessStrategy-scan-resistance-is-no.patchtext/x-patch; name="=?UTF-8?Q?v4-0003-Remove-BufferAccessStrategy-scan-resistance-is-no.patc?= =?UTF-8?Q?h?="Download+252-1492
#7Greg Burd
greg@burd.me
In reply to: Greg Burd (#6)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

On Fri, Jul 10, 2026, at 2:07 PM, Greg Burd wrote:

On Fri, Jul 10, 2026, at 10:00 AM, Greg Burd wrote:

Hello again,

Removing the freelist [1] turned out to be a good idea, so why not remove
and simplify more?

Included is a three-patch series (v3, on 2e6578292a9 cf-7000) that
replaces the 0..5 usage_count clock sweep with a cooling-stage clock,
and because this evictor algorithm is scan resistance 0003 removes
the BufferAccessStrategy ring buffers entirely. The net is slightly
faster (TPS), hotter buffers (fewer misses) and a lot less code to
maintain (no special case for scan resistant work) going forward.

0001 Batch the clock sweep to reduce nextVictimBuffer atomic contention
0002 Replace the usage_count clock sweep with a cooling-stage evictor
0003 Remove BufferAccessStrategy; scan resistance is now intrinsic

Net it is +510/-1642 over 83 files; almost all of the deletion is 0003.

On a 6-NUMA-node r8i.metal-96xl under continuous eviction, this change is
a consistent +4–5% on read-heavy pgbench with equal-or-better hit ratios.
The same test on a 2-node box is flat, which is the tell that the win
is the cross-socket clock-hand contention the batched sweep removes. Under
real storage IO (working set > RAM on local NVMe), the scan-resistance
path is +1.6–3.9% with measurably fewer heap reads.

I'll put the design decisions and the benchmark methodology on the table
first, then talk through the results because if the model is wrong on
principle, or the benchmarks are measuring the wrong thing, I would much
rather hear it now.

Contention arises on high core-count systems from the of cost advancing
nextVictimBuffer in StrategyGetBuffer() via a fetch-add of 1 per tick.
This contention is exacerbated on a multi-socket box. That one cache
line bounces over the interconnect on every eviction; under pressure
with hundreds of backends it is the dominant cost of the sweep. This is
why we're investing time on NUMA optimizations.

== What each patch does, and why ==

0001 -- Batch the clock sweep.

This is the setup/baseline commit and is an incremental improvement
over the existing batched approach, but it is not the final goal and
should not be committed without 0002.

Each backend will claim a run of consecutive buffer IDs with a single
fetch-add and iterate them privately. Global sweep order is preserved
-- each buffer is visited exactly once per pass -- only the intra-pass
ordering of visits changes, which the algorithm does not depend on.
The atomic fires ~1/batch as often.

The batch is one cache line's worth of hand values (PG_CACHE_LINE_SIZE
/ sizeof(uint32)), capped at NBuffers. It is gated on multi-node NUMA
(pg_numa_get_max_node() >= 1); on a single socket the batch is 1 and
the path is byte-identical to today. This is essentially Jim's patch;
the only substantive change from his v1/v2 is deriving the batch size
from the cache-line size rather than a fixed 64/tiered constant.

0002 -- Replace usage_count with a cooling-stage evictor.

This is the heart of the series and where I most want the design
attacked.

A buffer is HOT (recently used) or COOL (an eviction candidate);
"pinned" is the existing refcount. There is no per-buffer 0..5
counter. A demand-loaded page is admitted COOL (probationary); a
second access promotes it COOL->HOT. So a page touched once -- as is
the case with a sequential scan -- fills and drains the COOL stage and
is evicted from it without displacing the HOT working set. This is
the LeanStore / 2Q-A1 idea, and it is the whole reason 0003 can exist:
scan resistance stops being a ring-buffer bolt-on and becomes a
property of the replacement algorithm.

The 4-bit usage_count field is reinterpreted in place -- bit 0
HOT/COOL, bit 1 a reference bit, the top 2 bits unused -- so the 64-bit
buffer-state layout, its refcount / flag / lock offsets. The only
change to the StaticAsserts is a new one asserting the field is at
least 2 bits wide, since we now use two of its bits. The eviction
claim (COOL, unpinned -> pinned) stays a CAS so a racing PinBuffer
always wins; promotion and demotion are single-bit transitions.

The one non-obvious decision, which I got wrong first and want to flag
loudly: *Who* demotes HOT->COOL. My first cut was "prefer-COOL": the
foreground sweep skips HOT buffers hunting for an already-COOL victim
and only cools HOT buffers once a full pass finds none. That
collapses under an ordinary OLTP workload where the working set is
larger than shared_buffers (no scan at all). Every access promotes
its buffer to HOT, so there are almost no COOL buffers, the "cool a
full pass" fallback fires on nearly every allocation, and each victim
search becomes a ~2x full-pool scan of scattered BufferDescs. I
measured 3-17x throughput loss vs stock -- a cliff.

HOT->COOL demotion is done during the background writer's existing LRU
scan, which already runs ahead of the clock hand. It demotes just
enough HOT buffers to keep a supply of COOL victims (bounded by the
predicted next-cycle allocation, so it does not cool the whole pool),
under the buffer header lock it already holds. A single reference bit
gives a recently-accessed buffer one reprieve before it is cooled,
which keeps the genuinely-hot set out of the COOL stage under scan
pressure. With that, the foreground finds a victim in a single pass
and the cliff is gone (data below).

I chose prefer-COOL-plus-bgwriter-precooling because it can protect a
hot buffer slightly longer (the pre-cooler, tuned to a budget, decides
when to demote rather than demoting on contact), which should help hit
ratio on a stable hot set -- IF the pre-cooler keeps up. When it lags
(bgwriter off or behind), the foreground force_cool is still there as
a correctness fallback, but it is the expensive path.

0003 -- Remove BufferAccessStrategy.

With scan resistance intrinsic, the BAS_BULKREAD/BULKWRITE/VACUUM
rings are dead weight, so this removes them end to end: the type and
enum, the ring machinery in freelist.c, the strategy parameter
threaded through ReadBufferExtended / the ExtendBufferedRel* family /
read_stream / every scan/vacuum/analyze/index-AM caller, the strategy
fields on the scan and bulk-insert descriptors, and
_hash_getbuf_with_strategy. pg_stat_io's per-strategy IO contexts
collapse to normal/init (IOOP_REUSE only ever happened while recycling
a ring buffer, so it is gone), and the vacuum_buffer_usage_limit GUC /
VACUUM (BUFFER_USAGE_LIMIT ...) option / vacuumdb --buffer-usage-limit
go with it.

This is the patch most likely to be contentious for reasons unrelated
to the sweep: it removes a user-visible GUC and changes pg_stat_io's
shape. I have kept it as its own commit precisely so 0001+0002 can be
judged on the algorithm without swallowing the removal. If the
consensus is that the cooling model is fine but the ring machinery
should stay for other reasons (BUFFER_USAGE_LIMIT as an operator
control, say), 0003 can simply be dropped and 0002 still stands -- the
rings just become redundant rather than removed. I would like to know
if that is where people land.

== Benchmarks ==

I want to be careful here, because Andres's central criticism of the
original batched-sweep numbers [2] was that they measured the wrong
thing -- an all-in-page-cache config where the only bottleneck left is
the atomic, which is not how anyone runs a 384-vCPU box. That criticism
is correct and I have tried to design around it, but I have almost
certainly not fully escaped it, so the methodology is laid out below in
enough detail to shoot at.

I refer to the COOL/HOT approach (the changes in this patch set) as
"bcs", I've forgotten what that stands for... "buffer cache solution?"
I really don't remember (ha!).

Hardware / method (both instances bare-metal, Amazon Linux 2023):

- m6i.metal -- 128 vCPU, 2 sockets, 2 NUMA nodes (distances 10/20), 503GB
- r8i.metal-96xl -- 384 vCPU, 2 sockets, 6 NUMA nodes via SNC3, 3TB

Builds: --buildtype=debugoptimized (-O2), cassert off, --with-libnuma,
both branches from the same tree. Postmaster pinned with numactl
--cpunodebind=0 --membind=0 (without it stock TPS varied ~30% by launch
node -- worth flagging for anyone reproducing). pgbench dataset loaded
once per build into its own datadir.

The regime I settled on: a dataset that fits fully in OS page cache but
exceeds shared_buffers, warmed before each cell, caches NOT dropped
between cells. The point is a sweep that runs continuously with
sub-millisecond read latency and NO storage IO in the critical path --
so what I am measuring is the eviction machinery itself, not the disk
(as best as I can tell).

I sweep the ratio (working-set / shared_buffers) from 0.8 (fits, no
eviction, a control) up to 8x by varying shared_buffers against a fixed
63GB dataset.

3 iterations per cell, medians reported, 256 clients on m6i / 384 on r8i.

Two workloads: uniform pgbench -S (the pure eviction-churn case, and the
worst case for the cooling model -- no hot set to protect), and
"hotscan" (a Zipfian hot set plus a handful of clients running large
range scans -- the scan-resistance case).

I want the regime itself critiqued. It deliberately removes storage IO
to expose the sweep; the flip side is it is not a production
configuration, and the read-path win (fewer misses under scan
resistance) shows up as read count, not TPS, because a "miss" here is an
OS-cache memcpy, not a device read.

r8i.metal-96xl, uniform pgbench -S, 384 clients, TPS (median of 3):

ratio SB(GB) stock bcs delta stock/bcs hit%
0.8 ~400 1,623,536 1,692,090 +4.2% 99.2 / 99.9
1.25 ~320 1,443,343 1,519,290 +5.3% 94.8 / 95.2
1.5 ~266 1,344,051 1,417,462 +5.5% 91.9 / 92.1
2 ~200 1,293,871 1,358,550 +5.0% 87.9 / 87.8
4 ~100 1,194,405 1,240,334 +3.8% 77.1 / 78.5
8 ~50 1,090,873 1,140,839 +4.6% 69.7 / 70.6

Consistent +4-5% across the eviction range, growing with pressure, with
equal-or-better hit ratio (better at 4x/8x). bcs also showed lower
cache-miss rate in perf stat (~28-35% vs ~31-37%), which is the batched
sweep's reduced cross-node line bouncing showing through.

r8i, hotscan (Zipfian hot set + range scanners), TPS / hit% / heap reads:

ratio stock TPS bcs TPS stock hit bcs hit stock reads bcs reads
1.25 1,833,675 1,871,289 99.43 99.57 7,309,457 5,503,927
1.5 1,876,846 1,942,877 99.35 99.42 8,335,144 7,565,667
2 1,868,591 1,853,547 99.12 99.15 11,173,831 10,987,371

The scan-resistance signal: at 1.25-1.5x, bcs holds a higher hit ratio
and does up to 25% fewer heap reads (24.7% at 1.25x, ~9% at 1.5x) -- it
is keeping the hot set resident through the scans where stock lets them
flush it. Muted in TPS only because everything is in OS cache (a miss
is cheap); on real storage this read reduction is where the win would
land. At 2x it washes out (enough pressure that both evict heavily).

m6i.metal (2 nodes), uniform, 256 clients -- the smaller box, for contrast:
essentially parity, bcs -2% to +1% across ratios. The 2-node box barely
exercises the atomic, so 0001's contention win does not appear; that it does
not regress is the result that matters here.

Huge pages on vs off (r8i, uniform, medians): I ran this because the
original thread flagged it as uncharacterized. bcs won by +3-7% both
ways, no regression without huge pages -- the win is from cutting the
frequency of atomic ops on the counter line, which does not depend on
where the descriptors physically live. (This is why the batching gate
is NUMA-only and not also huge-pages-gated.)

The "prefer-COOL cliff" I mentioned under 0002, so the failure mode is
on the record: BEFORE moving cooling into the bgwriter, the m6i uniform
run at 256 clients was bcs 274K vs stock 840K at ratio 2 (-67%), and
42.8K vs 762K at 8x (-94%), with cache-miss rate exploding to ~40%.
That is the shape of getting the demotion policy wrong; the r8i +5%
table above is after the fix.

Reproduction: the whole harness (instance launch, OS tuning, per-build
load, the ratio sweep, perf stat capture) is scripted; I will attach it
as a DO-NOT-MERGE commit / put it in the CF entry so the methodology can
be reproduced and picked apart rather than taken on faith. Raw per-run
CSVs and perf output likewise.

A Real IO (working set > RAM, evictions hitting storage) Benchmark

This is the regime Andres asked for, and the one I flagged earlier as not yet
done cleanly. The earlier attempt was EBS-latency-bound; this one uses local
NVMe so eviction reads hit real storage at ~microsecond, not ~15ms, latency --
during the run the array sat at 100% utilization and ~145K read IOPS, so the
eviction path is genuinely storage-bound, not cache-served.

m6id.metal -- 128 vCPU, 2 sockets, 2 NUMA nodes, 503GB, 4x1.9TB local NVMe in
RAID0. Dataset ~700GB (pgbench scale 47000), i.e. LARGER than RAM, so the
working set cannot sit in the OS page cache. shared_buffers is a small window
over it -- 63GB (11x) and 31GB (22x) -- caches dropped per cell, 256 clients, 3
iterations, medians. Same builds/method as the in-cache runs otherwise.

hotscan (Zipfian hot set + range scanners), median of 3:

ratio SB(GB) stock TPS bcs TPS dTPS stock/bcs hit reads d
11 63 877,357 911,363 +3.9% 96.30 / 96.43 -2.2%
22 31 873,880 888,010 +1.6% 94.02 / 94.22 -1.5%

This is the result the in-cache runs could only hint at: under real storage IO
the scan-resistance read reduction converts to throughput. The bcs approach
keeps a higher hit ratio and does 1.5-2.2% fewer heap reads, and here -- unlike
in cache, where a miss is a cheap memcpy -- a read it avoids is an NVMe round
trip, so the read reduction shows up as +1.6-3.9% TPS.

uniform pgbench -S (pure eviction churn, no hot set to protect), median of 3:

ratio SB(GB) stock TPS bcs TPS dTPS stock/bcs hit
11 63 605,656 616,247 +1.7% 67.0 / 67.3
22 31 605,517 611,312 +1.0% 63.5 / 63.6

Hit ratio here is 63-67% -- a third of accesses miss and hit NVMe (190M-220M
evictions per run), so this is deep, genuinely storage-bound churn. bcs is
+1-1.7%, i.e. flat-to-slightly-positive, which is the honest production picture:
with no hot set to protect, scan resistance has nothing to do, and the win is
just the batched sweep's reduced contention showing faintly through the IO wait.
Notably bcs does not regress even when its policy has no advantage to exploit.

== Side note for the curious... ==

Separately, Dhruv Aron has proposed restructuring the shared-buffer lookup table
[2], replacing dynahash with a flat two-array structure. That attacks the
other hot cost on a buffer miss — resolving a page to its buffer — where this
series attacks the eviction that a miss triggers. They touch buf_table.c and a
lock-ordering change in InvalidateBuffer(); this series touches freelist.c and
the per-buffer replacement state, and removes BufferAccessStrategy. The two are
complementary and should compound on the miss path; the only overlap is
InvalidateBuffer()/GetVictimBuffer(), where their extended buffer-header-lock
hold and this series' CAS-claim + bgwriter pre-cooling both take that lock, and
would want reconciling if both land. I have not benchmarked them together (yet).

== What I have not done, honestly ==

- Hardening the foreground force_cool fallback to be cheap when it
fires, rather than relying on the bgwriter pre-cooler never lagging.

- Anything on single-socket beyond "does not regress"; the design is
not trying to help there.

== The ask ==

1. 0002's demotion policy: is prefer-COOL + bgwriter pre-cooling the
right call, or is the other team's cool-in-place the more robust
default given it has no cliff and no background-process dependency?
This is the decision everything else hangs on.

2. Is admitting demand-loaded pages COOL (probationary,
promote-on-second- touch) an acceptable basis for scan resistance
in the core buffer manager, i.e. is it OK to make scan resistance
an algorithm property and retire the strategy rings (0003)? Or
should the rings stay and 0002 ride alongside them?

3. The benchmark methodology: where is the in-OS-cache regime
misleading, and what would you want measured instead? I am most
worried I am flattering the sweep by removing the IO that would
otherwise hide it.

4. Reinterpreting the usage_count field as {HOT/COOL, ref} bits and
collapsing pg_stat_io's contexts -- acceptable, or is there a
cleaner representation the project would want before this is worth
pursuing?

I have measured that the 0..5 count is overhead and provides no
meaningful signal at all, that a HOT/COLD approach provides a simpler
more stable and better performing eviction model for the buffer pool.
If you dispute that, let's dig in and compare notes. :)

I would be remiss if I didn't point out the thread [3] by Tomas et. al.,
whose NUMA investigation targets the same bottlenecks, and inspired the
work that led to this set of ideas.

Thanks for reading this far. I look forward to the critique.

best.

-greg

[1] Reconsidering the freelist

/messages/by-id/f0e3c02e-e217-4f04-8dab-1e7e80a228c0@burd.me
[2] Re: Restructured Shared Buffer Hash Table

/messages/by-id/dbbd1998-19ff-4ac2-b4b1-a39f4ec1b0f5@iki.fi
[3] Adding basic NUMA awareness (Tomas Vondra)

/messages/by-id/099b9433-2855-4f1b-b421-d078a5d82017@vondra.me
Attachments:
* v3-0001-Batch-the-clock-sweep-to-reduce-nextVictimBuffer-.patch
* v3-0002-Replace-the-usage_count-clock-sweep-with-a-coolin.patch
* v3-0003-Remove-BufferAccessStrategy-scan-resistance-is-no.patch

Rebased v4 onto c71d43025d7.

-greg
Attachments:
* v4-0001-Batch-the-clock-sweep-to-reduce-nextVictimBuffer-.patch
* v4-0002-Replace-the-usage_count-clock-sweep-with-a-coolin.patch
* v4-0003-Remove-BufferAccessStrategy-scan-resistance-is-no.patch

Rebased v5 onto 5f14f82280d to keep the CF-bot happy (and everyone else, I'd imagine).

-greg

Attachments:

v5-0001-Batch-the-clock-sweep-to-reduce-nextVictimBuffer-.patchtext/x-patch; name="=?UTF-8?Q?v5-0001-Batch-the-clock-sweep-to-reduce-nextVictimBuffer-.patc?= =?UTF-8?Q?h?="Download+91-44
v5-0002-Replace-the-usage_count-clock-sweep-with-a-coolin.patchtext/x-patch; name="=?UTF-8?Q?v5-0002-Replace-the-usage=5Fcount-clock-sweep-with-a-coolin.pa?= =?UTF-8?Q?tch?="Download+229-86
v5-0003-Remove-BufferAccessStrategy-scan-resistance-is-no.patchtext/x-patch; name="=?UTF-8?Q?v5-0003-Remove-BufferAccessStrategy-scan-resistance-is-no.patc?= =?UTF-8?Q?h?="Download+252-1492
#8Greg Burd
greg@burd.me
In reply to: Greg Burd (#7)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

Hi,

v6 is attached, rebased on master (d7cd5d6).

0001 Batch the clock sweep to reduce nextVictimBuffer atomic contention
0002 Replace the usage_count clock sweep with a cooling-stage evictor
0003 Remove BufferAccessStrategy; scan resistance is now intrinsic

Two kinds of change since v5: three correctness fixes, and one bgwriter
tweak (folded into 0002) that deals with a regression I found while chasing
down whether removing StrategyRejectBuffer would hurt. Most of this mail is
that second thing, with numbers, because it is the part I most want picked
apart.

The three fixes first, briefly:

stats.out was a real bug. Once the rings are gone in 0003, pg_stat_io's
"reuses" column is NULL for every row, so the reset test's sum(reuses) came
back NULL, the \gset made an empty variable, and the next line died with
"syntax error at or near ':'". I dropped the now-meaningless reuses term
from the reset-test sums and regenerated the expected output.

force_cool now honours the reference bit. When the sweep has found no COOL
victim in a full pass and starts demoting HOT buffers, it applies the same
single second-chance ref-bit rule the bgwriter uses instead of demoting
unconditionally, so a buffer touched mid-pass isn't demoted a tick later.
And completePasses now counts an intra-batch wrap: with 0001's batched claim
a batch can straddle the NBuffers wrap, and that wasn't being reflected, so
the bgwriter's pass accounting could drift by up to a batch.

Let's talk regression...

The buffer cache ring strategy didn't only bound cache pollution, it also
deferred writeback. A backend running a big COPY, bulk UPDATE or VACUUM
reused a small ring and, via StrategyRejectBuffer, pushed dirty victims back
for the bgwriter and checkpointer to write instead of writing (and
WAL-flushing) them inline on its own allocation path. Pull the rings (as in
0003) and, done naively, that deferral goes too.

I wrote a small benchmark to see how much that costs. It's on an AWS
m6id.metal (Sapphire Rapids, 128 vCPU, 512 GB), with the data and WAL on the
instance-local NVMe (mdraid-0), not EBS. Both builds are meson
debugoptimized, cassert off, libnuma on; stock is master at d15a6bc2e16,
patched is the series on the same base.

Server pinned with "numactl --cpunodebind=0 --interleave=all",
non-default GUCs, same for both:

shared_buffers = 8GB # small enough that a bulk op spills
huge_pages = try
maintenance_work_mem = 1GB
max_wal_size = 64GB
min_wal_size = 8GB
checkpoint_timeout = 30min
checkpoint_completion_target = 0.9
wal_level = replica
fsync = on
synchronous_commit = on
track_io_timing = on

The table is ~4GB (20M rows, 180-byte payload, fillfactor 90), half of
shared_buffers, so a whole-table dirtying op has to evict dirty victims.
Each of the three statements runs on its own fresh cluster, three times,
median reported; IO stats are reset right before each timed statement so
the write count is per-statement, not cumulative:

CREATE TABLE bulk (id int, pad text) WITH (fillfactor=90);
INSERT INTO bulk SELECT g, repeat('x',180) FROM generate_series(1,2e7) g;
VACUUM (FREEZE) bulk; CHECKPOINT;

-- (1) UPDATE, dirties every page
UPDATE bulk SET id = id + 1;

-- (2) bulk INSERT into an empty table
CREATE TABLE ins (id int, pad text);
INSERT INTO ins SELECT g, repeat('y',180) FROM generate_series(1,2e7) g;

-- (3) VACUUM after dirtying half the pages
UPDATE bulk SET pad = repeat('z',180) WHERE id % 2 = 0; CHECKPOINT;
VACUUM bulk;

One caveat up front: (2) is INSERT...SELECT, not the COPY command. It hits
the same relation-extend + WAL-logged-write path BAS_BULKWRITE served, which
is what I wanted to stress, but if that distinction matters to anyone's
conclusion I'll re-run with COPY FROM.

For each statement I record time_s (wall seconds), bwrites (buffers the
backend wrote itself -- sum(writes) from pg_stat_io where object='relation'
after a forced flush, i.e. the writes the rings used to hand off), and
wal_gb (LSN delta, just a check the two builds did the same work). Full
series, Option B included:

workload metric stock patched impact of patches
------------ --------- ---------- ---------- ------------------
bulk UPDATE time_s 26.0 27.3 5% slower
bwrites 90754 158165 74% more
wal_gb 5.83 5.90 +1%
bulk INSERT time_s 14.2 15.6 10% slower
bwrites 0 0 none; extend path
wal_gb 5.00 5.11 +2%
VACUUM time_s 11.4 9.3 19% faster
bwrites 996255 390945 61% fewer
wal_gb 6.80 6.77 -1%

Per-run time_s so you can see the spread (stock / patched):
UPDATE 29.0 25.9 26.0 / 29.4 27.2 27.3
INSERT 15.1 14.2 13.4 / 15.5 15.6 15.7
VACUUM 11.5 11.4 11.4 / 9.2 9.3 9.4

The 5% and 10% are only a couple of run-to-run wobbles apart, but the
direction is steady across runs; the VACUUM win and the bwrites jump are
well clear of the noise.

That +74% bwrites on UPDATE is the crux: the backend is doing the writeback
the rings used to defer. Before I added the bgwriter tweak it was worse --
8% slower, 77% more writes -- and VACUUM was only 8% faster rather than 19%.
Two things I tried. First, having the sweep prefer a clean COOL victim and
skip dirty ones (bounded, so it can't turn into a full-pool rescan). That
made UPDATE *slower still*, 14% over stock, because under bulk dirtying the
clean victims simply aren't there and the skipping is wasted work. I threw
that out.

What stuck instead was letting the bgwriter keep up. Its per-cycle
clean-write cap is bgwriter_lru_maxpages, default 100; under bulk dirtying
the pool fills with dirty COOL buffers faster than 100/cycle can clean, the
bgwriter stalls at the cap, and the backend is left flushing victims itself.
So when the bgwriter's own predicted demand for the next cycle already
exceeds the cap, I let the cap follow demand:

write_limit = bgwriter_lru_maxpages;
if (upcoming_alloc_est > write_limit)
write_limit = upcoming_alloc_est;

It's still bounded -- by demand and by lapping the strategy point -- so it
can't run away, and anything whose demand sits below the cap
(i.e. everything that isn't bulk-dirtying) is untouched. A small change in
BgBufferSync, folded into 0002 in v6.

That halves the UPDATE slowdown and flips VACUUM into a clear win, but it
doesn't erase the residual, so I ran one more comparison to see where the
residual actually lives: patches 0001+0002 (rings still in place, 0003 not
applied). Same instance type, same three runs:

workload metric stock 0001+0002 impact
------------ --------- ---------- ---------- ------------------
bulk UPDATE time_s 25.8 25.4 1% faster
bwrites 90797 122416 35% more
bulk INSERT time_s 14.0 15.2 8% slower
bwrites 0 ~0 extend path
VACUUM time_s 11.5 9.7 15% faster
bwrites 986865 500190 49% fewer

So with the rings still doing their deferral, the new evictor on its own is
a touch faster on UPDATE and already a solid win on VACUUM. The 5% UPDATE
slowdown in the full series is therefore the price of removing the rings
(0003) specifically -- not the evictor -- and now I can say that against a
rings-kept baseline rather than just asserting it. The bgwriter tweak moves
those writes off the critical path (why the time recovers to 5%) but doesn't
reduce their number; some of that is just the cost of not having a private
ring to write-behind from.

Bulk INSERT is the more interesting one, because it's 8% slower even with
the rings kept -- so it's not about ring removal at all, it's something in
0001+0002 on the extend/write path (the pages are extended, not evicted:
bwrites is ~0, and it's WAL-bound at +2%). 0003 adds only the last two
points. I haven't fully root-caused it; my guess is lost write coalescing
on the extend path that BULKWRITE gave for free. It's small and contained,
and since it shows up without 0003 I expect it's fixable without bringing
the strategy machinery back. I'd rather understand it than wave at it, so
I'll follow up on that specifically.

The harness is just three psql scripts and the pg_stat_io / pg_stat_wal
deltas above; happy to post them if anyone wants to reproduce.

One loose end: 0003 drops the vacuum_buffer_usage_limit GUC, which only ever
sized the BAS_VACUUM ring. With no ring to size it does nothing, so I
removed it.

So the questions I'd most like opinions on:

Is a 5%-slower bulk UPDATE (the cost of retiring the rings) and an 8-10%
slower bulk INSERT (an extend-path effect independent of 0003) an acceptable
price for making scan resistance an algorithm property and dropping the
rings? Or would you rather the rings stayed and 0002 rode alongside them --
which the isolation run shows is a real option, since 0001+0002 with the
rings kept is neutral-to-better on the eviction-bound workloads? My hope
was to offer both a better/simpler algorithm for clock-sweep as well as
remove a lot of code.

Separately: is admitting demand-loaded pages COOL (probationary, promoted on
the second touch) an acceptable basis for scan resistance in core at all?
Where is my in-cache testing flattering the sweep, and what would you want
measured instead? And is reinterpreting the usage_count field as
{HOT/COOL, ref} bits, plus collapsing pg_stat_io's contexts, something you'd
accept, or is there a representation you'd want to see first?

This idea started with the question of, "I wonder if we really need a 5
count on each buffer in the clock-sweep eviction path or if that is simply
overhead and carries no signal at all for eviction or information useful to
the bgwriter?" FWIW, I feel that the COOL/HOT clock is simplier and that
tests show it is better than the 0..5 clock. I'm not thrilled by *any*
regression, especially not one that induces more WAL traffic, so I'm open to
thoughts on this.

That's all for now.

-greg

Attachments:

v6-0001-Batch-the-clock-sweep-to-reduce-nextVictimBuffer-.patchtext/x-patch; name="=?UTF-8?Q?v6-0001-Batch-the-clock-sweep-to-reduce-nextVictimBuffer-.patc?= =?UTF-8?Q?h?="Download+109-44
v6-0002-Replace-the-usage_count-clock-sweep-with-a-coolin.patchtext/x-patch; name="=?UTF-8?Q?v6-0002-Replace-the-usage=5Fcount-clock-sweep-with-a-coolin.pa?= =?UTF-8?Q?tch?="Download+260-89
v6-0003-Remove-BufferAccessStrategy-scan-resistance-is-no.patchtext/x-patch; name="=?UTF-8?Q?v6-0003-Remove-BufferAccessStrategy-scan-resistance-is-no.patc?= =?UTF-8?Q?h?="Download+261-1490
#9Greg Burd
greg@burd.me
In reply to: Greg Burd (#8)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

v7 is attached, rebased on master (2521f9e).

best.

-greg

Attachments:

t139487_9
v7-0001-Batch-the-clock-sweep-to-reduce-nextVictimBuffer-.patchtext/x-patch; name="=?UTF-8?Q?v7-0001-Batch-the-clock-sweep-to-reduce-nextVictimBuffer-.patc?= =?UTF-8?Q?h?="Download+109-44
v7-0002-Replace-the-usage_count-clock-sweep-with-a-coolin.patchtext/x-patch; name="=?UTF-8?Q?v7-0002-Replace-the-usage=5Fcount-clock-sweep-with-a-coolin.pa?= =?UTF-8?Q?tch?="Download+260-89
v7-0003-Remove-BufferAccessStrategy-scan-resistance-is-no.patchtext/x-patch; name="=?UTF-8?Q?v7-0003-Remove-BufferAccessStrategy-scan-resistance-is-no.patc?= =?UTF-8?Q?h?="Download+268-1495
#10Ants Aasma
ants.aasma@cybertec.at
In reply to: Greg Burd (#8)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

On Tue, 14 Jul 2026 at 20:53, Greg Burd <greg@burd.me> wrote:

The buffer cache ring strategy didn't only bound cache pollution, it also
deferred writeback. A backend running a big COPY, bulk UPDATE or VACUUM
reused a small ring and, via StrategyRejectBuffer, pushed dirty victims back
for the bgwriter and checkpointer to write instead of writing (and
WAL-flushing) them inline on its own allocation path. Pull the rings (as in
0003) and, done naively, that deferral goes too.

...

One caveat up front: (2) is INSERT...SELECT, not the COPY command. It hits
the same relation-extend + WAL-logged-write path BAS_BULKWRITE served, which
is what I wanted to stress, but if that distinction matters to anyone's
conclusion I'll re-run with COPY FROM.

That is not quite right. First of all, bulk update and insert..select
do not use a bulkwrite strategy. COPY and CREATE TABLE AS do.
Secondly, you have it the wrong way around, the idea of bulkwrite is
to have the backend itself handle the writes. It reduces cache
pollution, but it also forces the bulk writer to handle the work it
created, so unrelated backends don't get slowed down doing the work
that the bulk writer created. StrategyRejectBuffer is only for bulk
reads. A seq scan based update would be using BAS_BULKREAD, but as you
noted, after 256kB it will fall back to normal allocation.

If we are to get rid of the ring buffer then I think we need some
other way to apply backpressure on bulk writers. Just making the
bgwriter more aggressive doesn't help when writing out the modified is
the bottleneck. It will just fall behind until every allocation is
forced to do a flush.

So the differences observed in the bulk insert and update workloads
isn't explained by getting rid of the ring buffers. It must be related
to differing eviction patterns. I think that should be easy to
demonstrate by also benchmarking only the first two patches.

This idea started with the question of, "I wonder if we really need a 5
count on each buffer in the clock-sweep eviction path or if that is simply
overhead and carries no signal at all for eviction or information useful to
the bgwriter?" FWIW, I feel that the COOL/HOT clock is simplier and that
tests show it is better than the 0..5 clock. I'm not thrilled by *any*
regression, especially not one that induces more WAL traffic, so I'm open to
thoughts on this.

I think the initial results are a promising indication that G-CLOCKs
counters are not providing much value. But to have confidence in not
regressing we need to cover a much wider gamut of access patterns.
Another design dimension is the pure miss rate, which is relevant for
direct I/O based systems and miss rate when combined with kernel page
cache. Seems to me that a more systematic way to generate different
access pattern mixes is needed to identify potential regressions.

Btw. on the surface the algorithm looks like CLOCK-Pro [1]https://www.usenix.org/legacy/event/usenix05/tech/general/full_papers/jiang/jiang.pdf. The
literature might offer some interesting tuning tips.

Regards,
Ants

[1]: https://www.usenix.org/legacy/event/usenix05/tech/general/full_papers/jiang/jiang.pdf

#11Andres Freund
andres@anarazel.de
In reply to: Greg Burd (#8)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

Hi,

On 2026-07-14 13:53:06 -0400, Greg Burd wrote:

The buffer cache ring strategy didn't only bound cache pollution, it also
deferred writeback. A backend running a big COPY, bulk UPDATE or VACUUM
reused a small ring and, via StrategyRejectBuffer, pushed dirty victims back
for the bgwriter and checkpointer to write instead of writing (and
WAL-flushing) them inline on its own allocation path. Pull the rings (as in
0003) and, done naively, that deferral goes too.

That's not right, StrategyRejectBuffer does that only for a bulkread
strat. And because of the limited number of buffers used for bulkwrite, they
don't actually tend to involve bgwriter a lot.

The effect is literally the opposite for bulk copies. Without the strategy
there's basically no back pressure against a backend dirtying a lot during
bulk writes, with the strategy there's a *lot* (perhaps too much)
backpressure.

There is no such handing off.

CREATE TABLE bulk (id int, pad text) WITH (fillfactor=90);
INSERT INTO bulk SELECT g, repeat('x',180) FROM generate_series(1,2e7) g;
VACUUM (FREEZE) bulk; CHECKPOINT;

-- (1) UPDATE, dirties every page
UPDATE bulk SET id = id + 1;

-- (2) bulk INSERT into an empty table
CREATE TABLE ins (id int, pad text);
INSERT INTO ins SELECT g, repeat('y',180) FROM generate_series(1,2e7) g;

-- (3) VACUUM after dirtying half the pages
UPDATE bulk SET pad = repeat('z',180) WHERE id % 2 = 0; CHECKPOINT;
VACUUM bulk;

One caveat up front: (2) is INSERT...SELECT, not the COPY command.

INSERT ... SELECT does not use the bulkwrite strategy, so there can't be an
effect from removing the strategies until the VACUUM.

It hits the same relation-extend + WAL-logged-write path BAS_BULKWRITE
served, which is what I wanted to stress, but if that distinction matters to
anyone's conclusion I'll re-run with COPY FROM.

It's not at all the "same relation-extend + WAL-logged-write path". INSERT
SELECT uses individual heap_inserts, COPY uses heap_multi_insert. The latter
extends does a lot less WAL logging and extends the relation much more
aggressively (heap_insert() only bulk extends if there's contention).

For each statement I record time_s (wall seconds), bwrites (buffers the
backend wrote itself -- sum(writes) from pg_stat_io where object='relation'
after a forced flush, i.e. the writes the rings used to hand off), and
wal_gb (LSN delta, just a check the two builds did the same work). Full
series, Option B included:

workload metric stock patched impact of patches
------------ --------- ---------- ---------- ------------------
bulk UPDATE time_s 26.0 27.3 5% slower
bwrites 90754 158165 74% more
wal_gb 5.83 5.90 +1%
bulk INSERT time_s 14.2 15.6 10% slower
bwrites 0 0 none; extend path
wal_gb 5.00 5.11 +2%
VACUUM time_s 11.4 9.3 19% faster
bwrites 996255 390945 61% fewer
wal_gb 6.80 6.77 -1%

Af course VACUUM is faster if you allow it to fill up all of shared buffers
with dirty buffers. FWIW, you can get that effect today using
VACUUM (BUFFER_USAGE_LIMIT 0)

Since a bulk update doesn't use a strategy, I don't see how strategy related
changes can trigger 74% more writes via bgwriter.

Greetings,

Andres Freund

#12Andres Freund
andres@anarazel.de
In reply to: Ants Aasma (#10)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

Hi,

FWIW, I think it's quite unhelpful to combine a thread to make a relatively
local optimization (batched clock sweep) with huge changes to how buffer
replacement works. I don't see how those things are related.

On 2026-07-22 17:52:02 +0300, Ants Aasma wrote:

I think the initial results are a promising indication that G-CLOCKs
counters are not providing much value.

I am doubtful that the benchmarks in this thread actually shows anything
particularly interesting wrt that. There's just not enough interesting IO in
pgbench -S:

- The indexes are too small compared to the table data to be interesting, even
index leaf pages are unlikely to be evicted.

- The fact that realistically there's at max two misses for a single query
(one index leaf page and the heap page) rather substantially limits the max
impact the wrong buffer replacement triggers.

- I don't think the hit ratio is an all that interesting way to evaluate the
cache replacement, particularly for something like pgbench -S. It really
matters *which* buffers are evicted.

- pgbench -S has about the same penalty for an index miss as for a heap miss
(since both trigger one synchronous IO wait and since there's 1 leaf page
and one heap access per query). That's not true for many other workloads.

Greetings,

Andres Freund

#13Ants Aasma
ants.aasma@cybertec.at
In reply to: Andres Freund (#12)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

Generally agreed, but I wanted to understand one point a bit better.

On Wed, 22 Jul 2026 at 18:29, Andres Freund <andres@anarazel.de> wrote:

- I don't think the hit ratio is an all that interesting way to evaluate the
cache replacement, particularly for something like pgbench -S. It really
matters *which* buffers are evicted.

Is that only because a single bad eviction could cause multiple
backends to wait for an I/O? To me, a high probability of multiple
accesses within one disk latency would imply that the buffer is really
hot and the eviction algorithm would have to be really terrible to
evict it. In other words, I have a hard time imagining when it would
matter in practice.

Of course with prefetching, distinguisghing prefetched misses from
synchronous misses is very much important.

Ants

#14Andres Freund
andres@anarazel.de
In reply to: Ants Aasma (#13)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

Hi,

On 2026-07-22 19:09:18 +0300, Ants Aasma wrote:

Generally agreed, but I wanted to understand one point a bit better.

On Wed, 22 Jul 2026 at 18:29, Andres Freund <andres@anarazel.de> wrote:

- I don't think the hit ratio is an all that interesting way to evaluate the
cache replacement, particularly for something like pgbench -S. It really
matters *which* buffers are evicted.

Is that only because a single bad eviction could cause multiple
backends to wait for an I/O?

Mainly that, yea. If you evict, in the most extreme case, an inner index page,
it's quite likely that you cause multiple backends to wait once you have a
decent amount of concurrency. Even with index leaf pages that's not
unlikely. Whereas it's rather unlikely for a heap page, just due to the larger
number of those.

It's probably less of an issue with local NVMe latencies, but once you have
cloud storage involved...

To me, a high probability of multiple accesses within one disk latency would
imply that the buffer is really hot and the eviction algorithm would have to
be really terrible to evict it.

I think it won't happen that often if the working set is close to s_b, because
the replacement rate is relatively low. But if the working set is considerably
larger than s_b + kernel page cache and you have a non-uniform access model
(so there's a decent number of accesses even to a small number of heap pages),
it's not hard to have a replacement algorithm that can't distinguish between
the importance of the index pages vs heap pages.

Of course with prefetching, distinguisghing prefetched misses from
synchronous misses is very much important.

Indeed. One thing, in the context of the current buffer replacement model, I
have been wondering about, is cabining how far async reads can increase the
usage count. Differentiating that would make it more likely that synchronously
needed buffers survive for longer.

Greetings,

Andres Freund

#15Greg Burd
greg@burd.me
In reply to: Ants Aasma (#10)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

On Wed, Jul 22, 2026, at 10:52 AM, Ants Aasma wrote:

On Tue, 14 Jul 2026 at 20:53, Greg Burd <greg@burd.me> wrote:

The buffer cache ring strategy didn't only bound cache pollution, it also
deferred writeback. A backend running a big COPY, bulk UPDATE or VACUUM
reused a small ring and, via StrategyRejectBuffer, pushed dirty victims back
for the bgwriter and checkpointer to write instead of writing (and
WAL-flushing) them inline on its own allocation path. Pull the rings (as in
0003) and, done naively, that deferral goes too.

...

Hi Ants,

Thanks -- this was the most useful kind of review, because you caught a place
where I had the mechanism plain wrong, and the correction changes the story
for the better.

One caveat up front: (2) is INSERT...SELECT, not the COPY command. It hits
the same relation-extend + WAL-logged-write path BAS_BULKWRITE served, which
is what I wanted to stress, but if that distinction matters to anyone's
conclusion I'll re-run with COPY FROM.

That is not quite right. First of all, bulk update and insert..select
do not use a bulkwrite strategy. COPY and CREATE TABLE AS do.
Secondly, you have it the wrong way around, the idea of bulkwrite is
to have the backend itself handle the writes. It reduces cache
pollution, but it also forces the bulk writer to handle the work it
created, so unrelated backends don't get slowed down doing the work
that the bulk writer created. StrategyRejectBuffer is only for bulk
reads. A seq scan based update would be using BAS_BULKREAD, but as you
noted, after 256kB it will fall back to normal allocation.

You're right on both counts, and I went and re-read the code to be sure I
understood rather than just nodding. GetBulkInsertState() is the only thing
that sets BAS_BULKWRITE, and its callers are COPY (copyfrom.c) and CREATE
TABLE AS / matview refresh (createas.c). nodeModifyTable has no bulk-insert
state, so a plain INSERT..SELECT or UPDATE runs through the normal allocation
path with no strategy at all. And StrategyRejectBuffer() bails immediately
unless the strategy is BAS_BULKREAD. So my "the rings deferred writeback to
the bgwriter" framing was simply wrong: BAS_BULKWRITE keeps the writes *on*
the backend that made them. Thank you for the correction; I've dropped that
explanation entirely.

If we are to get rid of the ring buffer then I think we need some
other way to apply backpressure on bulk writers. Just making the
bgwriter more aggressive doesn't help when writing out the modified is
the bottleneck. It will just fall behind until every allocation is
forced to do a flush.

This is the real blocker and I think you're correct. I tested it directly: a
sustained bulk-dirtying workload with the bgwriter driven as hard as it will
go, and the backend's share of relation writes still climbs to ~100% over
time -- the bgwriter simply falls behind and every allocation ends up flushing
inline, precisely as you describe. So "make the bgwriter keep up" is not a
backpressure mechanism, and I'm no longer proposing it as one.

The consequence is that I'm splitting the series and dropping the
BufferAccessStrategy removal (old 0003) from what I'm asking anyone to
consider now. Removing the rings needs a real replacement for the bulk-writer
backpressure BAS_BULKWRITE provides, and I don't have one I'm happy with yet;
that's its own project. What's left is two independent pieces:

- the batched clock sweep (contention), and
- the cooling-stage evictor (replacing the 0..5 usage_count),
- with the rings left in place.

which I think is also the right thing to do per Andres's point that these
shouldn't ride in one thread.

So the differences observed in the bulk insert and update workloads
isn't explained by getting rid of the ring buffers. It must be related
to differing eviction patterns. I think that should be easy to
demonstrate by also benchmarking only the first two patches.

That is exactly what it turned out to be, and your suggested test is the one
that showed it. Benchmarking 0001+0002 with the rings still in place (0003
not applied), the bulk-UPDATE difference is already present -- so it is an
eviction/writeback-path effect from the cooling change, not from removing the
strategies. With that isolation done and a corrected, real-COPY dirty-bulk
run, the "regression" I reported in the earlier mail largely does not survive:
the bulk-UPDATE delta was inside run-to-run noise once I stopped conflating
INSERT..SELECT with COPY and stopped letting a stale checkpoint bleed into the
timing. I'm not going to defend the earlier numbers; they were measuring the
wrong thing.

This idea started with the question of, "I wonder if we really need a 5
count on each buffer in the clock-sweep eviction path or if that is simply
overhead and carries no signal at all for eviction or information useful to
the bgwriter?" FWIW, I feel that the COOL/HOT clock is simplier and that
tests show it is better than the 0..5 clock. I'm not thrilled by *any*
regression, especially not one that induces more WAL traffic, so I'm open to
thoughts on this.

I think the initial results are a promising indication that G-CLOCKs
counters are not providing much value. But to have confidence in not
regressing we need to cover a much wider gamut of access patterns.
Another design dimension is the pure miss rate, which is relevant for
direct I/O based systems and miss rate when combined with kernel page
cache. Seems to me that a more systematic way to generate different
access pattern mixes is needed to identify potential regressions.

Agreed, and this is where I've put the measurement effort since. The clearest
result so far is on the *cost* of the 0..5 counter rather than its cache-hit
quality: on a large shared_buffers pool with huge_pages off, when the pool is
hot and eviction is continuous, the stock clock hand has to decrement a hot
buffer up to five times to make it evictable, and it resets its scan counter
on every decrement -- so it does on the order of 5*NBuffers descriptor visits
per victim, each a likely dTLB miss across the (multi-GB) descriptor array. I
instrumented ticks-per-victim: on a 384-vCPU box at a residency fraction where
the pool stays hot, stock runs ~18 clock-hand visits per victim while the
1-bit HOT/COOL sweep runs ~1.6 -- roughly an order of magnitude less
descriptor-scanning work per eviction, and it doesn't grow the way the 0..5
grind does. Under enough concurrent eviction pressure that turns into a
throughput cliff / multi-second p99 for stock that the cooling sweep doesn't
have. I'm writing this up properly (methodology, the residency-fraction model
that predicts where it bites, huge_pages on/off, and where a connection pooler
mitigates it) and will post it as its own thing rather than bury it here.

On the systematic access-pattern point: yes. What I've been driving is a
residency model -- touches-per-buffer before eviction is f/(1-f) for residency
fraction f = shared_buffers/working_set -- which predicts the regime where the
0..5 counter can climb enough to matter (f roughly 0.8-0.9) versus where it
can't (f ~ 0.5, every buffer dies at usage ~1 and the counter is pure
overhead). I'd like to turn that into the "generate a matrix of access
patterns" harness you're describing: {residency fraction} x {uniform, skewed}
x {buffered vs direct IO} x {index-heavy vs heap-heavy}, reporting per-page-kind
eviction (an inner index page evicted stalls many backends; a heap page,
fewer) rather than a single hit ratio. If you have a workload set you consider
representative I'd rather start from that than invent my own.

Btw. on the surface the algorithm looks like CLOCK-Pro [1]. The
literature might offer some interesting tuning tips.

Thanks for the pointer -- I'd been thinking of it in LeanStore / 2Q terms
(demand-loaded pages admitted COOL/probationary, promoted to HOT on a second
touch), but the CLOCK-Pro framing of hot/cold with a test period is close and
I'll read it properly again before I make any claims about the admission policy.
If the literature has a better-justified promotion rule than "second touch,"
I'd rather adopt it than defend an ad-hoc one.

Regards,
Ants

[1]
https://www.usenix.org/legacy/event/usenix05/tech/general/full_papers/jiang/jiang.pdf

Thanks again for the careful read.

best.

-greg

#16Greg Burd
greg@burd.me
In reply to: Andres Freund (#11)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

On Wed, Jul 22, 2026, at 11:10 AM, Andres Freund wrote:

Hi,

Hi Andres,

You and Ants landed on the same core correction, so I've owned the
StrategyRejectBuffer / BAS_BULKWRITE mistake in my reply to him rather than
repeat it here -- short version: you're both right, BAS_BULKWRITE keeps the
writes on the backend, there is no hand-off, and I've dropped that framing.
Let me answer the points that were specific to your mail.

On 2026-07-14 13:53:06 -0400, Greg Burd wrote:

The buffer cache ring strategy didn't only bound cache pollution, it also
deferred writeback. A backend running a big COPY, bulk UPDATE or VACUUM
reused a small ring and, via StrategyRejectBuffer, pushed dirty victims back
for the bgwriter and checkpointer to write instead of writing (and
WAL-flushing) them inline on its own allocation path. Pull the rings (as in
0003) and, done naively, that deferral goes too.

That's not right, StrategyRejectBuffer does that only for a bulkread
strat. And because of the limited number of buffers used for bulkwrite, they
don't actually tend to involve bgwriter a lot.

The effect is literally the opposite for bulk copies. Without the strategy
there's basically no back pressure against a backend dirtying a lot during
bulk writes, with the strategy there's a *lot* (perhaps too much)
backpressure.

There is no such handing off.

Right, and this reframes the whole thing for me. I had been treating the
ring's writeback behavior as a cost to preserve; it's actually backpressure to
preserve. I confirmed the "no backpressure without it" direction directly: a
sustained bulk-dirtying workload with the bgwriter pushed as hard as it goes
still ends with the backend doing ~100% of the relation writes itself -- the
bgwriter just falls behind and every allocation flushes inline. So removing
the rings removes real backpressure and "make the bgwriter keep up" does not
replace it.

The consequence is that I'm dropping the BufferAccessStrategy removal (old
0003) from what I'm putting forward. It needs a genuine replacement for the
bulk-writer backpressure BAS_BULKWRITE provides, which is its own project, and
your "perhaps too much" aside is interesting on its own -- if the current
backpressure is heavier than it needs to be, the right move might be to tune
or rethink it rather than either keep it as-is or delete it. But that's not
this patch. What I'm left proposing is two independent things: the batched
sweep, and the cooling-stage evictor with the rings left in place.

CREATE TABLE bulk (id int, pad text) WITH (fillfactor=90);
INSERT INTO bulk SELECT g, repeat('x',180) FROM generate_series(1,2e7) g;
VACUUM (FREEZE) bulk; CHECKPOINT;

-- (1) UPDATE, dirties every page
UPDATE bulk SET id = id + 1;

-- (2) bulk INSERT into an empty table
CREATE TABLE ins (id int, pad text);
INSERT INTO ins SELECT g, repeat('y',180) FROM generate_series(1,2e7) g;

-- (3) VACUUM after dirtying half the pages
UPDATE bulk SET pad = repeat('z',180) WHERE id % 2 = 0; CHECKPOINT;
VACUUM bulk;

One caveat up front: (2) is INSERT...SELECT, not the COPY command.

INSERT ... SELECT does not use the bulkwrite strategy, so there can't be an
effect from removing the strategies until the VACUUM.

It hits the same relation-extend + WAL-logged-write path BAS_BULKWRITE
served, which is what I wanted to stress, but if that distinction matters to
anyone's conclusion I'll re-run with COPY FROM.

It's not at all the "same relation-extend + WAL-logged-write path". INSERT
SELECT uses individual heap_inserts, COPY uses heap_multi_insert. The latter
extends does a lot less WAL logging and extends the relation much more
aggressively (heap_insert() only bulk extends if there's contention).

RelationGetBufferForTuple/RelationAddBlocks extends by num_pages scaled by the
extension-lock waiter count, so a single-tuple heap_insert extends one page
unless it's contended, while heap_multi_insert asks for many; and
heap_multi_insert emits one XLOG_HEAP2_MULTI_INSERT per batch versus one record
per row. So (2) in my script was not exercising the BAS_BULKWRITE path at all,
and it was not the "same" write/extend path as COPY. Both criticisms are
correct and the workload was mislabeled.

For each statement I record time_s (wall seconds), bwrites (buffers the
backend wrote itself -- sum(writes) from pg_stat_io where object='relation'
after a forced flush, i.e. the writes the rings used to hand off), and
wal_gb (LSN delta, just a check the two builds did the same work). Full
series, Option B included:

workload metric stock patched impact of patches
------------ --------- ---------- ---------- ------------------
bulk UPDATE time_s 26.0 27.3 5% slower
bwrites 90754 158165 74% more
wal_gb 5.83 5.90 +1%
bulk INSERT time_s 14.2 15.6 10% slower
bwrites 0 0 none; extend path
wal_gb 5.00 5.11 +2%
VACUUM time_s 11.4 9.3 19% faster
bwrites 996255 390945 61% fewer
wal_gb 6.80 6.77 -1%

Af course VACUUM is faster if you allow it to fill up all of shared buffers
with dirty buffers. FWIW, you can get that effect today using
VACUUM (BUFFER_USAGE_LIMIT 0)

Yes. I re-ran this and you're right: the VACUUM "win" is just the removal of
the BAS_VACUUM self-throttle, not anything my change does. With a corrected
run the patched VACUUM time is ~the same as stock, and stock with
BUFFER_USAGE_LIMIT 0 gets essentially the same effect. I've withdrawn the
VACUUM-speedup claim; it was measuring the throttle, not the evictor.

Since a bulk update doesn't use a strategy, I don't see how strategy related
changes can trigger 74% more writes via bgwriter.

They can't, and that was the tell that my explanation was wrong. The +74%
was not a strategy effect -- a bulk UPDATE uses no strategy in either build.
Isolating it (benchmarking 0001+0002 with the rings still present, which is
also the test Ants suggested) shows the write-shifting comes from the cooling
change in 0002 -- specifically an over-eager bgwriter tweak I had folded in
there ("Option B") that pushed writes off the backend onto the bgwriter. That
was an eviction-path change riding in 0002, not ring removal, and given the
backpressure discussion above it's the wrong behavior anyway, so I'm removing
it. Once it's out, the bulk-UPDATE delta between stock and the cooling evictor
sits inside run-to-run noise; I won't be presenting it as a win or a
regression.

Greetings,

Andres Freund

Net: the honest residue of that whole benchmark is "I mismeasured." Where I
think there is a real, defensible result is on the *cost of the 0..5
usage_count itself* under a hot, continuously-evicting large pool with
huge_pages off -- the clock hand doing ~5*NBuffers dTLB-missing descriptor
visits per victim because it decrements a hot buffer five times and resets its
scan counter on every decrement. I've been measuring that (ticks-per-victim,
and the residency-fraction regime where it bites) and will bring it as its own
thread with a methodology built to your "real IO, not all-in-page-cache,
measure which buffers get evicted" bar rather than tacking it onto this one.

Thanks for the corrections and insights.

best.

-greg

#17Greg Burd
greg@burd.me
In reply to: Greg Burd (#16)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

Hello again.

Well, I've been using the past few days to dig into the
HOT/COOL+batching model and I think I've found the corners and have
data to support it. Attached find v8 (on 9fa2c1e) and a tar.gz with
my benchmark.

TL;DR:
Empirically-grounded data shows that HOT/COOL+batching:
- (a) provides scan resistance which is net new outside of the
ring buffer
- (b) is simpler (1-bit vs 0..5) <- that's a lot fewer bits, lol!
- (c) I've found no evidence of any signal provided by the 0..5
clock-sweep counter and direct evidence that it can, in
limited but not-unimaginable cases, become the root of
pathologically bad performance
- (d) batching de-contends nextVictimBuffer on NUMA +68-75% when
the sweep is hot, no regression otherwise. Not "always
faster," but "targeted de-contention that pays off exactly
when the sweep saturates, and never hurts when pooled.

I've tested with pgbench, HammerDB (TPROC-C/H), and custom code to
model and elicit the worst/best cases and the pay-off cases I could
imagine. I've not done this on local (non-cloud) hardware (I'm
building that out now!) but on EC2 metal instances which I'll argue
are close enough and all I had available (for now).

I may still be benchmarking in ways that you feel don't elicit the
best, most accurate results. Feel free to point that out and I'll
adapt and re-run the tests.

AI NOTICE:
Yes, this patch was co-developed/refined/tested/benchmarked using
AI/LLMs and even this email started off as LLM content. All the work
is steered by me and reviewed before submission by me, I stand behind
it warts and all. (gulp) ;-P

DETAILS:
I've done quite a bit of measurement. This reply is long because
it needs to be, legitimate requests for real data required thought
and time to execute, analyze, etc. Short version of what changed and
what I found:

- Two patches now, not three. I dropped the BufferAccessStrategy
removal; it removed real bulk-writer backpressure with no
replacement (Ants and Andres were both right), so it's out until
that's solved separately.
- I withdrew the v6/7 dirty-bulk "regression" and VACUUM "win" as
both were mismeasurements, detailed in my earlier replies.
- The batched sweep (0001) and the cooling evictor (0002) are
independent and I'm treating them as such; 0001 stands alone if 0002
is contentious, but used together is the real win.
- I built a sweep-bound microbenchmark that isolates the
nextVictimBuffer atomic, and it settles the batching question with
perf c2c evidence (below). It also shows honestly where batching
does *not* move the needle.

The series:

0001 Batch the clock sweep to reduce nextVictimBuffer atomic contention
(Jim Mlodgenski's idea; co-authored)
0002 Replace the 0..5 usage_count clock sweep with a 1-bit HOT/COOL
cooling-stage evictor (LeanStore/2Q-A1), rings kept

All numbers below are on AWS bare metal, working set sized to fit RAM so
misses are OS-page-cache-cheap (this is a CPU/TLB study, not a storage
one -- I'll say explicitly where that matters). Instrumentation (a
throwaway ticks-per-victim counter and a block-thrash function) is not
part of the patches; the repro kit is attached as repro.tgz_ (underscore
to avoid being picked up by CI I hope).

0001 -- batched sweep: does it help NUMA, and does it regress?
========================================================================

Andres, in the first reply on this thread you doubted that batching
independent of contention/usage-rate would help, and suspected it might
hurt. I set out to prove or disprove that, and the answer is: it depends
entirely on whether the clock sweep is the bottleneck, and I can now
show exactly when that is the case.

First, the fair-throughput question. Earlier I got confusing results
because I was running 2000-8000 backends against a 384-vCPU box -- 5-20x
oversubscribed -- which is a scheduler-storm test, not a batching test,
and there batching *did* look like it hurt (~-35%). With a connection
count at or below core count (the pooled configuration anyone runs in
production), on a 6-NUMA r8i.metal (384 vCPU), uniform pgbench-style
point lookups, stock vs batch{1,16,64} are all within ~1-2% at every
level -- batching neither helps nor hurts. So: no regression when
you're not oversubscribed; the -35% was an artifact of oversubscription,
which a connection pooler removes.

Why neutral there? Because in a normal point-lookup the executor +
protocol + tuple deform dwarf the one atomic; even stock only did ~2.9
clock ticks per victim, so nextVictimBuffer just wasn't hot enough to
matter.

To find out whether the *mechanism* is real at all, I built a
sweep-bound microbenchmark: a function that pins+releases random blocks
of a table 1.7x shared_buffers with the normal (non-ring) strategy, so
StrategyGetBuffer is essentially the only work. 360 concurrent loops (=
cores on the instance), 64GB shared_buffers, 6 NUMA nodes:

build block-evictions/sec vs stock
stock 2,096,314 --
bcs batch=1 2,138,485 +2% (single fetch-add: parity)
bcs batch=16 3,531,021 +68%
bcs batch=64 3,664,458 +75%

and the mechanism, straight from perf c2c (cross-node HITM on the
hottest line):

stock: StrategyGetBuffer's fetch-add on nextVictimBuffer is the #1
cross-node HITM line at 38.5%, spread across all 6 nodes.
batch=16/64: that same line drops to ~1-2% HITM.

So the batched claim does exactly what it claims: nextVictimBuffer is
touched ~1/N as often, its cross-socket bounce collapses, and when the
sweep is the bottleneck throughput rises 68-75%. batch=1 == stock
confirms the win is purely the batched atomic, not the evictor.

The honest framing, which I'd rather state than have pulled out of me:
this is a microbenchmark that isolates the atomic. It proves the
mechanism is real and bounds the ceiling; it does not claim a 68% win on
your OLTP box. On real mixed workloads at backends<=cores the sweep is
a small share of each query, so end-to-end it's neutral -- no
regression, no headline speedup -- until the sweep actually saturates
(large hot pool, heavy eviction, many NUMA nodes, which is the reported
field pathology). 0001 is a targeted de-contention that pays off
exactly when the hand is hot and costs nothing when it isn't; on a
single socket it compiles to the stock path (batch size 1).

I did also run HammerDB TPROC-C at backends<=cores; it's
write/lock/WAL-bound, the sweep is not the bottleneck, and batch=64 was
~5% *below* stock (the cooling bookkeeping isn't free when the sweep
isn't the cost). Reporting that too -- it's not a batching workload,
and I'm not going to pretend it is.

0002 -- cooling evictor: why the 0..5 counter, and the failure mode
========================================================================

Ants, you said the initial results were a promising indication that
G-CLOCK's 0..5 counter isn't carrying much value, and asked for a wider
access-pattern gamut. Here's the strongest thing I found.

The 0..5 counter has a pathological failure mode that the 1-bit HOT/COOL
clock does not. Stock must decrement a buffer from 5 to 0 before it can
evict it -- up to five visits per victim -- and it resets its
bounded-scan counter on every decrement, so under a hot,
continuously-evicting pool it never gives up early. With a large
shared_buffers and huge_pages off (a multi-GB BufferDescriptors array on
4KB pages), those repeated visits are dTLB misses. I measured
ticks-per-victim climbing to ~9 on stock under load where the 1-bit
clock stays flat (it demotes HOT->COOL in one pass; a victim is produced
in at most ~3 ticks, and typically 1-2). In the extreme this turns into
a throughput cliff / multi-second p99 for stock -- the field reports of
"raise shared_buffers and TPS falls off" on large no-huge-pages
machines. The 1-bit clock has no such cliff because it does bounded
work per victim by construction.

On scan resistance, which is the reason the 0..5 counter's replacement
has to be careful: a demand-loaded page is admitted COOL (probationary)
and only promoted to HOT on a genuine second touch, so a one-touch
sequential scan fills and drains the COOL stage without displacing the
HOT working set. I tested this adversarially -- an OLTP hot set that
fits in shared_buffers, hit by point lookups, while a concurrent seqscan
streams a table 4x shared_buffers, on local NVMe (no EBS/IOPS cap
masking the eviction rate):

OLTP hit ratio under the scan: stock 99.999% bcs 99.998%
OLTP resident set: flat in both

i.e. the cooling evictor's scan resistance is at parity with what the
BAS_BULKREAD ring gives today. (An earlier version of this patch also
removed that read ring on the theory that COOL admission made it
redundant; local-NVMe testing showed it wasn't quite -- the ring is
measurably tighter under concurrent scan -- so I kept the ring. That's
the dropped 0003.)

On the literature: you pointed at CLOCK-Pro, thanks -- the admission
model here (probationary COOL, promote on reuse) is the 2Q-A1 /
test-period idea and I'd rather adopt a better-justified promotion rule
from that line than defend "second touch" if the list prefers one. I
tried a stricter "promote only on a repeat reference" variant; it was
neutral on OLTP and didn't improve scan resistance, so I reverted it to
keep 0002 minimal, but it's an easy knob.

On backpressure: I'm not removing the rings, precisely because
BAS_BULKWRITE is backpressure, not just a pollution guard. If we ever
do want the rings gone, that backpressure needs a real replacement
first; I don't have one worth proposing, so it's out of scope here.

Relationship to the NUMA partitioned sweep
========================================================================

Tomas, this overlaps your NUMA series and I want to be clear it's not a
competitor. 0001 attacks the same nextVictimBuffer cross-node
contention your partitioned sweep does, but as a minimal,
placement-agnostic change: one fetch-add per batch instead of per tick,
no per-node partitioning, no buffer-placement changes, batch-size-1
fallback so it's a no-op off NUMA. The c2c data above is the same
contention your partitioning targets, measured in isolation.

I think these compose rather than conflict: a partitioned sweep still
advances a hand within each partition, and batching that per-partition
hand should give the same cross-node relief inside a partition that it
gives the global hand here. If your series lands, 0001 is a small
change on top (or moot, if the partition hands are already node-local
and uncontended). If it's useful I'm happy to test batching layered on
your patches -- I just didn't want to fold placement/partitioning into
this, since the evictor and the atomic de-contention stand on their own
and are far smaller.

What I'm claiming, and what I'm not
========================================================================

- 0002 is simpler (1 bit vs a 0..5 counter), has intrinsic scan
resistance at parity with the BAS_BULKREAD ring, and removes a real
large-pool/no-huge-pages cliff that the 0..5 counter can hit. It is
throughput-neutral on ordinary OLTP (within noise), which is the bar
for a replacement-policy change.
- 0001 de-contends nextVictimBuffer on NUMA: +68-75% when the sweep is
the bottleneck (c2c-proven mechanism), neutral otherwise, no-op off
NUMA, no regression when connections are kept at/under core count.
One idea would be to lower batch size to 1 when the number of
backends > cores available on the host to avoid the regression, but
that isn't in v8 nor have I tested it in practice.
- I am NOT claiming a general TPS win; on write/lock-bound or
non-sweep-bound workloads there isn't one, and I've shown that.

Repro kit attached (methodology, the microbenchmark, the ticks/victim
and block-thrash instruments, the residency-fraction model that predicts
when the 0..5 cliff appears, and the huge_pages on/off and
confound-discrimination notes). It's built to be re-run and attacked;
if any of this doesn't reproduce for you, that's the feedback I want.

Thanks for the reviews, they made the patch set smaller and the claims
honest.

best.

-greg

Attachments:

t139487_17
v8-0001-Batch-the-clock-sweep-to-reduce-nextVictimBuffer-.patchtext/x-patch; name="=?UTF-8?Q?v8-0001-Batch-the-clock-sweep-to-reduce-nextVictimBuffer-.patc?= =?UTF-8?Q?h?="Download+109-44
v8-0002-Replace-the-usage_count-clock-sweep-with-a-coolin.patchtext/x-patch; name="=?UTF-8?Q?v8-0002-Replace-the-usage=5Fcount-clock-sweep-with-a-coolin.pa?= =?UTF-8?Q?tch?="Download+239-89
repro.tgz_application/octet-stream; name=repro.tgz_Download
#18Andres Freund
andres@anarazel.de
In reply to: Greg Burd (#17)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

Hi,

On 2026-07-29 13:56:41 -0400, Greg Burd wrote:

AI NOTICE:
Yes, this patch was co-developed/refined/tested/benchmarked using
AI/LLMs and even this email started off as LLM content. All the work
is steered by me and reviewed before submission by me, I stand behind
it warts and all. (gulp) ;-P

Not a fan. I'm ok with some closely supervised AI written code, but AI written
emails I tend to just put to the bottom of my pile (which I rarely reach). I
can't tell how much vetting goes into them, and I have too much to do to be an
AI sanity checker.

To find out whether the *mechanism* is real at all, I built a
sweep-bound microbenchmark: a function that pins+releases random blocks
of a table 1.7x shared_buffers with the normal (non-ring) strategy, so
StrategyGetBuffer is essentially the only work. 360 concurrent loops (=
cores on the instance), 64GB shared_buffers, 6 NUMA nodes:

build block-evictions/sec vs stock
stock 2,096,314 --
bcs batch=1 2,138,485 +2% (single fetch-add: parity)
bcs batch=16 3,531,021 +68%
bcs batch=64 3,664,458 +75%

Seems pretty doubtful this is measuring something super interesting, given
that you'd a) use a lot of memory bandwidth for that much IO b) there's a lot
of other contention points for that much IO (e.g. buffer table partition
locks).

A 75% win actually seems somewhat disappointing for such a microbenchmark? I'd
bet that you'd see WAY bigger gains with properly partitioning this (since the
batching still leaves you with a lot of c2c accesses).

I still don't see what the point of pursuing batching instead of helping out
with Tomas' partitioned sweep patch is.

0002 -- cooling evictor: why the 0..5 counter, and the failure mode
========================================================================

Ants, you said the initial results were a promising indication that
G-CLOCK's 0..5 counter isn't carrying much value, and asked for a wider
access-pattern gamut. Here's the strongest thing I found.

Bla.

The 0..5 counter has a pathological failure mode that the 1-bit HOT/COOL
clock does not. Stock must decrement a buffer from 5 to 0 before it can
evict it -- up to five visits per victim -- and it resets its
bounded-scan counter on every decrement, so under a hot,
continuously-evicting pool it never gives up early. With a large
shared_buffers and huge_pages off (a multi-GB BufferDescriptors array on
4KB pages), those repeated visits are dTLB misses. I measured
ticks-per-victim climbing to ~9 on stock under load where the 1-bit
clock stays flat (it demotes HOT->COOL in one pass; a victim is produced
in at most ~3 ticks, and typically 1-2). In the extreme this turns into
a throughput cliff / multi-second p99 for stock -- the field reports of
"raise shared_buffers and TPS falls off" on large no-huge-pages
machines. The 1-bit clock has no such cliff because it does bounded
work per victim by construction.

That's a lot of words with no actual evidence what the real world cost of that
is.

On scan resistance, which is the reason the 0..5 counter's replacement
has to be careful: a demand-loaded page is admitted COOL (probationary)
and only promoted to HOT on a genuine second touch, so a one-touch
sequential scan fills and drains the COOL stage without displacing the
HOT working set. I tested this adversarially -- an OLTP hot set that
fits in shared_buffers, hit by point lookups, while a concurrent seqscan
streams a table 4x shared_buffers, on local NVMe (no EBS/IOPS cap
masking the eviction rate):

OLTP hit ratio under the scan: stock 99.999% bcs 99.998%
OLTP resident set: flat in both

I don't think it makes much sense to measure this when you can achieve close
to 100 hit rate, since that's so easy to achieve. There's dozens of ways to
achieve scan resistance that work well in that scenario but that fall entirely
completely flat when you have actual cache pressure.

I think this algorithm will *trivially* fail in a lot of cases without
strategies. Once you have sufficient cache pressure the likelihood for
cold->hot promotion ends up being too low to happen reliably before cold pages
are evicted. And because this doesn't have something like a ghost directory,
it'll not even have a chance to detect and fix that the next time the page is
read in.

I don't believe this algorithm will actually work well even without concurrent
bulk access, I suspect it'll fail to work well even with something like
pgbench -S where the index fits comfortably in s_b, but the table data does
not. We already aren't good in that kind of workload, but I don't see how
your algorithm will actually achieve *any* meaningful protection for anything
but the inner index pages, the likelihood of repeat accesses to the same index
page won't be high enough to keep those pages warm.

On the literature: you pointed at CLOCK-Pro, thanks -- the admission model
here (probationary COOL, promote on reuse) is the 2Q-A1 / test-period idea
and I'd rather adopt a better-justified promotion rule from that line than
defend "second touch" if the list prefers one. I tried a stricter "promote
only on a repeat reference" variant; it was neutral on OLTP and didn't
improve scan resistance, so I reverted it to keep 0002 minimal, but it's an
easy knob.

FWIW, I really doubt that any of the scan resistant algorithms actually will
allow us to get the read side strategies entirely, even with a ghost
directory. All of the scan resistant algorithms only are scan resistant if
repeated accesses (that should be cached) that occur during a concurrent bulk
scan are actually within something like the size of the buffer pool, but that
doesnt' really seem good enough.

That said, the current logic of strategies, where we actually evict the pages
with a ringbuffer, seems rather wrong, because it takes approximately forever
to fill the buffer pool even if there *are* repeat accesses.

I suspect we should go for something like an extended version of
S3-FIFO. S3-FIFO, has a FIFO for cold pages, taking up a certain percentage of
the buffer pool (10% IIRC) and a ghost directory to detect repeat accesses
that don't happen while the page is still in the cold fifo queue. That's scan
resistant as long as repeat accesses are frequent enough (1.10x the buffer
pool, I think).

To avoid bulk reads from pushing repeat accesses beyond the window in which
repeat accesses are recognized, I think we could "just" make sure the ghost
directory has eviction logic that will not allow strategy reads to take up
more than a certain percentage of the ghost directory.

Greetings,

Andres Freund

#19Greg Burd
greg@burd.me
In reply to: Andres Freund (#18)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

On Wed, Jul 29, 2026, at 4:10 PM, Andres Freund wrote:

Hi,

On 2026-07-29 13:56:41 -0400, Greg Burd wrote:

AI NOTICE:
Yes, this patch was co-developed/refined/tested/benchmarked using
AI/LLMs and even this email started off as LLM content. All the work
is steered by me and reviewed before submission by me, I stand behind
it warts and all. (gulp) ;-P

Not a fan. I'm ok with some closely supervised AI written code, but AI written
emails I tend to just put to the bottom of my pile (which I rarely reach). I
can't tell how much vetting goes into them, and I have too much to do to be an
AI sanity checker.

Fair, and I'll stop using AI/LLMs in that way. I'd rather you judge the work than wonder how much I vetted it.

The last email was vetted by me. Was it too wordy - yes, was it a bit robot-voice - yes. I get it, human voice is "authentic" and that investment has meaning.

To find out whether the *mechanism* is real at all, I built a
sweep-bound microbenchmark: a function that pins+releases random blocks
of a table 1.7x shared_buffers with the normal (non-ring) strategy, so
StrategyGetBuffer is essentially the only work. 360 concurrent loops (=
cores on the instance), 64GB shared_buffers, 6 NUMA nodes:

build block-evictions/sec vs stock
stock 2,096,314 --
bcs batch=1 2,138,485 +2% (single fetch-add: parity)
bcs batch=16 3,531,021 +68%
bcs batch=64 3,664,458 +75%

Seems pretty doubtful this is measuring something super interesting, given
that you'd a) use a lot of memory bandwidth for that much IO b) there's a lot
of other contention points for that much IO (e.g. buffer table partition
locks).

It is super-interesting because this condition does happen in practice with customer instances and has caused at least one cloud provider to create an internal work-around for it. Yes, there are other points where this workload has contention but right now I'm focused on this one.

A 75% win actually seems somewhat disappointing for such a microbenchmark? I'd
bet that you'd see WAY bigger gains with properly partitioning this (since the
batching still leaves you with a lot of c2c accesses).

Maybe, but 75% isn't bad especially when the p99 during the overloaded state for clock-sweep is seconds to minutes and for HOT/COOL+batching is as steady as in any state. That's a real win IMO.

I still don't see what the point of pursuing batching instead of helping out
with Tomas' partitioned sweep patch is.

I'll do both, I appreciate the serious consideration of this algorithm. There's no reason the two ideas can't be combined. A partitioned HOT/COOL might be a solid improvement on its own, without partitioning.

0002 -- cooling evictor: why the 0..5 counter, and the failure mode
========================================================================

Ants, you said the initial results were a promising indication that
G-CLOCK's 0..5 counter isn't carrying much value, and asked for a wider
access-pattern gamut. Here's the strongest thing I found.

Bla.

Gesundheit.

The 0..5 counter has a pathological failure mode that the 1-bit HOT/COOL
clock does not. Stock must decrement a buffer from 5 to 0 before it can
evict it -- up to five visits per victim -- and it resets its
bounded-scan counter on every decrement, so under a hot,
continuously-evicting pool it never gives up early. With a large
shared_buffers and huge_pages off (a multi-GB BufferDescriptors array on
4KB pages), those repeated visits are dTLB misses. I measured
ticks-per-victim climbing to ~9 on stock under load where the 1-bit
clock stays flat (it demotes HOT->COOL in one pass; a victim is produced
in at most ~3 ticks, and typically 1-2). In the extreme this turns into
a throughput cliff / multi-second p99 for stock -- the field reports of
"raise shared_buffers and TPS falls off" on large no-huge-pages
machines. The 1-bit clock has no such cliff because it does bounded
work per victim by construction.

That's a lot of words with no actual evidence what the real world cost of that
is.

This is the definition of the pathological case observed in production that Jimbo looked into when he was first trying out ideas that eventually led to the batching proposal.

The ticks-per-victim is a reasonable metric for "ability to make progress" and ~9 vs 3 can be meaningful in practice. The "real-world cost" is that your p99 latency spikes to seconds/minutes ore more in practice with the clock-sweep/0..5 algorithm but does not with HOT/COOL+batched.

On scan resistance, which is the reason the 0..5 counter's replacement
has to be careful: a demand-loaded page is admitted COOL (probationary)
and only promoted to HOT on a genuine second touch, so a one-touch
sequential scan fills and drains the COOL stage without displacing the
HOT working set. I tested this adversarially -- an OLTP hot set that
fits in shared_buffers, hit by point lookups, while a concurrent seqscan
streams a table 4x shared_buffers, on local NVMe (no EBS/IOPS cap
masking the eviction rate):

OLTP hit ratio under the scan: stock 99.999% bcs 99.998%
OLTP resident set: flat in both

I don't think it makes much sense to measure this when you can achieve close
to 100 hit rate, since that's so easy to achieve. There's dozens of ways to
achieve scan resistance that work well in that scenario but that fall entirely
completely flat when you have actual cache pressure.

You're right that 99.99% hit rate is the easy regime and not the interesting one. The interesting question is testing your prediction, which I'll do. I'll see which page either algorithm protects/preserves in cache.

I think this algorithm will *trivially* fail in a lot of cases without
strategies. Once you have sufficient cache pressure the likelihood for
cold->hot promotion ends up being too low to happen reliably before cold pages
are evicted. And because this doesn't have something like a ghost directory,
it'll not even have a chance to detect and fix that the next time the page is
read in.

I don't believe this algorithm will actually work well even without concurrent
bulk access, I suspect it'll fail to work well even with something like
pgbench -S where the index fits comfortably in s_b, but the table data does
not. We already aren't good in that kind of workload, but I don't see how
your algorithm will actually achieve *any* meaningful protection for anything
but the inner index pages, the likelihood of repeat accesses to the same index
page won't be high enough to keep those pages warm.

On the literature: you pointed at CLOCK-Pro, thanks -- the admission model
here (probationary COOL, promote on reuse) is the 2Q-A1 / test-period idea
and I'd rather adopt a better-justified promotion rule from that line than
defend "second touch" if the list prefers one. I tried a stricter "promote
only on a repeat reference" variant; it was neutral on OLTP and didn't
improve scan resistance, so I reverted it to keep 0002 minimal, but it's an
easy knob.

FWIW, I really doubt that any of the scan resistant algorithms actually will
allow us to get the read side strategies entirely, even with a ghost
directory. All of the scan resistant algorithms only are scan resistant if
repeated accesses (that should be cached) that occur during a concurrent bulk
scan are actually within something like the size of the buffer pool, but that
doesnt' really seem good enough.

Only one way to find out, I'll test this.

That said, the current logic of strategies, where we actually evict the pages
with a ringbuffer, seems rather wrong, because it takes approximately forever
to fill the buffer pool even if there *are* repeat accesses.

Agreed.

I suspect we should go for something like an extended version of
S3-FIFO. S3-FIFO, has a FIFO for cold pages, taking up a certain percentage of
the buffer pool (10% IIRC) and a ghost directory to detect repeat accesses
that don't happen while the page is still in the cold fifo queue. That's scan
resistant as long as repeat accesses are frequent enough (1.10x the buffer
pool, I think).

That should be easy enough to test.

To avoid bulk reads from pushing repeat accesses beyond the window in which
repeat accesses are recognized, I think we could "just" make sure the ghost
directory has eviction logic that will not allow strategy reads to take up
more than a certain percentage of the ghost directory.

First, I appreciate your time reviewing this. I think it's time well spent and I'll try to make sure to voice my replies myself, warts and all.

Second, I'll review and add voice to Tomas's thread. This is a valid algorithm and I think they compose. I'll consider efficient ways to manage a ghost directory.

Greetings,

Andres Freund

best.

-greg

#20Greg Burd
greg@burd.me
In reply to: Greg Burd (#19)
Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention

On 2026-07-29, Andres Freund wrote:

I don't believe this algorithm will actually work well even without
concurrent bulk access [...] the likelihood of repeat accesses to the
same index page won't be high enough to keep those pages warm.

Andres,

Me again. I like testable predictions, so I tested it. :)

I'll keep this to just this one question: under real cache pressure, with the index resident and the heap much larger than shared_buffers, does the HOT/COOL+batching fail to protect index pages relative to today's 0..5 clock?

I hope I got that objection correctly framed, otherwise my tests might be invalid. I'm sure you'll (bluntly) let me know if I have. ;-)

Setup:
- i4i.metal (128 vCPU, 2 NUMA nodes), data on local NVMe RAID0.
- shared_buffers = 4GB, huge_pages on.
- pgbench scale 2000: pgbench_accounts heap = 25GB (6.25x s_b),
primary key index = 4.3GB (~1.1x s_b). So the index does not fit
with room to spare; its leaf pages genuinely compete for the pool
and do get evicted.
As you pointed out, a tiny always-resident index would prove
nothing.
- 96 clients, 10-minute measured runs, autovacuum off.
- Two access distributions:
a) uniform pgbench -S (the case you named)
b) a zipfian point lookup (s=1.2) so the hot subset of rows is
re-read often.
- I compare stock master against the two-patch series, both built
identically, with a small throwaway instrument patch that counts
evictions per relfilenode.
- Using that I classified each evicted relation as heap or index
via pg_class. The metric is the share of evictions that fall on
index pages. If the HOT/COOL+batch protects index pages worse
than stock, it should evict them at a higher share (right?).

Results (I had an LLM format this table):
index heap-evict index-evict
workload build evict % (buffers) (buffers)
------------------ ----- ------- ----------- -----------
uniform -S stock 38.41% 616,467,240 384,451,330
uniform -S bcs 35.06% 639,240,582 345,178,854
zipfian s=1.2 stock 30.17% 12,266,976 5,299,490
zipfian s=1.2 bcs 29.28% 11,665,582 4,828,986

Change in index-eviction share (bcs - stock):
uniform -3.35 pt
zipfian -0.89 pt

Throughput, same runs:
uniform stock 1.126M tps -> bcs 1.309M tps (+16%)
zipfian stock 1.153M tps -> bcs 1.305M tps (+13%)

So the proposed new algorithm (HOT/COOL+batch) evicts index pages at a slightly lower rate than the existing one (0..5 clock) not a higher one which refutes your claim in this particular benchmark. That results in higher throughput with these two patches for that workload, I'll take that win.

I'm guessing that a primary-key leaf page backs on the order of a few hundred heap rows, so any given leaf is re-touched far more often per-unit time than any single heap page. Under COOL admission that reuse arrives well before the leaf drains out of the COOL stage, so the leaf promotes to HOT and survives.

I don't disagree with the promotion race you described, I think it is real. However, in this workload the index-leaf reuse rate simply clears the bar.

I've not added any form of "ghost directory" which could potentially improve this even more, to your point. A page whose second access lands after it has already been evicted from COOL is gone with no way to learn/remember that. Or, it may turn out that the maintenance of a ghost directory could outweigh the benefits I can't say from this one benchmark.

I don't know if S3-FIFO is a better approach than the one I propose, no data yet. To say it is "trivially going to fail" is a statement worth testing and my next target for the next email on this thread. My bet is that it won't, but the only way to find out (and convince you and others that this approach is worth consideration) is to try it out and post numbers.

If I missed something or you'd like the test adjusted let me know and I'll give it a whirl.

best.

-greg