Preempt_none Is Dead; Your Postgres Probably Doesn't Care

ryantsuji1 pts0 comments

PREEMPT_NONE Is Dead; Your Postgres Probably Doesn’t Care — The Build

A benchmark came out of AWS earlier this month showing PostgreSQL throughput on Linux 7.0 dropping to 0.51x what the same workload produced on Linux 6.x. The Phoronix headline wrote itself. Hacker News did what Hacker News does. By the end of the week, I had been asked by three separate clients whether they needed to hold their kernel upgrades.

They don’t. Almost nobody does. The regression is real, but it’s a narrow, loud artifact of a benchmark configuration that was already misconfigured for a 96-vCPU box with 100+ GB of shared memory. The headline undersells how much this is a “don’t do that” story and oversells how much this is a Linux-broke-Postgres story.

Let me walk through what actually happened, because the explanation is interesting on its own merits — it touches the scheduler, the TLB, page faults, and the one spinlock in Postgres that nobody outside the buffer manager thinks about. And it ends where a lot of Postgres performance stories end: with huge pages.

Lætitia Avrot wrote her own clear-eyed walkthrough of the same regression on My DBA Notebook on April 15, and if you read only one piece on this, read hers. What follows is my version of the story, with more time spent on the mechanism and a few more diagrams.

What Linux 7.0 actually changed

Before Linux 7.0, you could build or boot a kernel in one of three preemption modes:

PREEMPT_NONE — the kernel almost never interrupts a running userspace thread. Thread gets its slice, uses its slice, yields on syscall or sleep. This is what you historically wanted on a server: batch-throughput-friendly, minimum context-switch overhead.

PREEMPT_FULL — the kernel can interrupt userspace at almost any safe point. Low latency, lots of context switches, historically the desktop default.

PREEMPT_LAZY — a newer middle ground. The scheduler can interrupt, but will wait for “natural” boundaries when it can.

Linux 7.0, via Peter Zijlstra’s preemption-cleanup series, removed PREEMPT_NONE entirely on arm64, x86, powerpc, riscv, s390, and loongarch. What you get now is PREEMPT_FULL or PREEMPT_LAZY. On most distros the default shifted to PREEMPT_LAZY.

For nearly every workload this is fine. PREEMPT_LAZY is supposed to approximate PREEMPT_NONE behavior under throughput-oriented loads. Most of the time it does. The exception is when a userspace thread enters a critical section where getting preempted is catastrophic — and then stays in that critical section just a little longer than the scheduler expects.

Spinlocks, in other words.

The benchmark

Salvatore Dipietro at AWS posted the regression to LKML on April 3 as a one-patch series titled “sched: Restore PREEMPT_NONE as default.” The setup:

m8g.24xlarge — 96 vCPU Graviton4 — running Amazon Linux 2023.

Kernel: next-20260331, a linux-next snapshot, with and without a revert of commit 7dadeaa6e851 (“sched: Further restrict the preemption modes”), which is the 7.0-rc1 change that removed PREEMPT_NONE on arm64.

Storage: 12× 1 TB io2 volumes at 32,000 IOPS each, RAID0, XFS.

PostgreSQL 17, pgbench simple-update, 1,024 clients, 96 threads, prepared protocol, scale factor 8,470, fillfactor=90, 1,200 second runs.

The results, averaged over three runs each:

Configuration<br>Avg tps<br>Ratio

Baseline (linux-next, PREEMPT_LAZY)<br>50,751.96<br>1.00x

With 7dadeaa6e851 reverted<br>98,565.86<br>1.94x

The baseline is the one that got the headline. Reverting the preemption-mode change nearly doubles throughput. Stated the other direction: on this workload, Linux 7.0 delivers 0.51x.

perf showed the regression sitting, almost undiluted, in a single call chain:

Copy<br>1|- 56.03% - StartReadBuffer<br>2 |- 55.93% - GetVictimBuffer<br>3 |- 55.93% - StrategyGetBuffer<br>4 |- 55.60% - s_lock<br>5 |- 0.08% - LockBufHdr<br>6 |- 0.07% - hash_search_with_hash_value

More than half of the machine’s CPU time is being burned on one userspace spinlock, which is a very specific and very telling place for the hot spot to land.

Why that spinlock, and why now

StrategyGetBuffer is the function Postgres calls when a backend needs a buffer and doesn’t already have one. It serializes on one spinlock — StrategyControl->buffer_strategy_lock — in two cases. On a cold buffer pool, it pops from the freelist. Once the freelist drains, it runs the clock sweep, advances nextVictimBuffer, and returns a candidate. Both paths take the same spinlock. On a 96-vCPU machine with 1,024 clients, any serialization point will get loud, but this one has a specific property: the section protected by the spinlock can, under the wrong configuration, take a minor page fault.

That’s the piece that turns a bad-but-tolerable serialization point into a 55%-of-CPU catastrophe, and it’s where the kernel change matters. The contention is real at any parallelism this high. The question is why it got twice as bad under PREEMPT_LAZY. The answer — as Andres Freund worked out on -hackers and on Hacker News — is not the...

linux preempt_none postgres kernel spinlock preempt_lazy

Related Articles