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

From: "Greg Burd" <greg(at)burd(dot)me>
To: "Andres Freund" <andres(at)anarazel(dot)de>
Cc: "PostgreSQL Hackers" <pgsql-hackers(at)lists(dot)postgresql(dot)org>, "Tomas Vondra" <tomas(at)vondra(dot)me>, "Nathan Bossart" <nathandbossart(at)gmail(dot)com>
Subject: Re: [PATCH] Batched clock sweep to reduce cross-socket atomic contention
Date: 2026-07-10 14:00:02
Message-ID: 5bcaa70b-89c7-44b6-b197-638d10997cae@app.fastmail.com
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

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
https://www.postgresql.org/message-id/f0e3c02e-e217-4f04-8dab-1e7e80a228c0%40burd.me
[2] Re: Restructured Shared Buffer Hash Table
https://www.postgresql.org/message-id/dbbd1998-19ff-4ac2-b4b1-a39f4ec1b0f5@iki.fi
[3] Adding basic NUMA awareness (Tomas Vondra)
https://www.postgresql.org/message-id/099b9433-2855-4f1b-b421-d078a5d82017%40vondra.me

Attachment Content-Type Size
v3-0001-Batch-the-clock-sweep-to-reduce-nextVictimBuffer-.patch text/x-patch 8.9 KB
v3-0002-Replace-the-usage_count-clock-sweep-with-a-coolin.patch text/x-patch 27.5 KB
v3-0003-Remove-BufferAccessStrategy-scan-resistance-is-no.patch text/x-patch 177.4 KB

In response to

Responses

Browse pgsql-hackers by date

  From Date Subject
Next Message Tom Lane 2026-07-10 14:08:05 Re: use of SPI by postgresImportForeignStatistics
Previous Message Aleksander Alekseev 2026-07-10 13:44:06 [PATCH] Cover get_json_table_plan() with tests