vatt'ghern jaskier's ballads

同一套硬體、同一份 pgbench workload,只換了一顆 kernel 帶來的搶佔模式預設值,PostgreSQL 17 的 tps 就從 98,565.86 掉到 50,751.96——腰斬。

PREEMPT_NONE 消失後,Postgres 效能腰斬的追兇

級 kernel 前,多數工程師會去翻跟自己服務直接相關的那幾行 changelog:網路堆疊怎麼變、檔案系統有沒有新旗標、cgroup 記帳方式改了什麼。排程器的搶佔模式通常不在這份清單上——它決定核心什麼時候可以打斷一段正在跑的 userspace 程式碼,聽起來完全是排程器內部的事,跟資料庫吞吐量之間,理論上只隔著小數點以後幾位。Linux 7.0 把 PREEMPT_NONE 整個拿掉,只留下 PREEMPT_FULL 跟較新的 PREEMPT_LAZY 當預設,arm64、x86、powerpc、riscv、s390、loongarch 都受影響,多數發行版選了 PREEMPT_LAZY 當預設。AWS 工程師 Salvatore Dipietro 四月初把一組 benchmark 貼上 LKML,附上一份標題直白的 patch——「sched: Restore PREEMPT_NONE as default.」——把 PREEMPT_NONE 加回去當預設。理由很簡單:他的 PostgreSQL 17 benchmark,tps 腰斬了。這類搶佔語意的變更平常不會出現在資料庫團隊的升級檢查清單上——沒有人會為了升級 kernel,去讀排程器子系統改了哪個安全點的定義。Dipietro 的 benchmark 講的是另一回事:同一份 pgbench workload、同一顆 CPU,只是搶佔模式跟著新 kernel 換了,tps 就少了將近一半,而且不是尾延遲變差這種常見的「感覺變慢了」,是穩定狀態下的平均吞吐量直接腰斬。

PREEMPT_NONE(已拿掉) 「kernel almost never interrupts a running userspace thread」——安全點極少 PREEMPT_FULL 「kernel can interrupt userspace at almost any safe point」——安全點密集 PREEMPT_LAZY(現在的預設) 「newer middle ground」——安全點密度居中,但缺頁服務中途也算數
三種搶佔模式對「安全點」密度的定義。PREEMPT_LAZY 的安全點裡,包含了一段程式碼正在服務 page fault 的當下。

PREEMPT_LAZY 的定義讀起來像是一種折衷:「The scheduler can interrupt, but will wait for 'natural' boundaries when it can.」——排程器仍然可以打斷,但只要等得到,就會撐到「自然」的邊界再動手,聽起來比 PREEMPT_FULL 更保守,也比較不會在關鍵時刻硬生生打斷正在跑的程式碼。正是這種「聽起來更溫和」的設計,讓 tps 腰斬這件事顯得更難解釋——如果搶佔真的變得比較克制,吞吐量照理應該更穩,不該掉到只剩一半。把三種模式放在一起看反差更清楚:「kernel almost never interrupts a running userspace thread」是 PREEMPT_NONE 的定義,「kernel can interrupt userspace at almost any safe point」是 PREEMPT_FULL 的定義,PREEMPT_LAZY 卡在兩者中間,理論上不該比兩個極端都更容易觸發這種等級的退化。

tps 腰斬,變因只有一個

Dipietro 的測試機器是 m8g.24xlarge,96 vCPU 的 Graviton4,跑 Amazon Linux 2023,kernel 用的是 linux-next-20260331 這個快照。儲存是 12 顆 1 TB io2(32K IOPS)組成的 RAID0、XFS 檔案系統,資料庫是 PostgreSQL 17。workload 是 pgbench 的 simple-update,1,024 個 client、96 個 thread、走 prepared 協定,scale factor 8,470、fillfactor 90,每輪跑 1,200 秒,取 3 輪平均。這套設定在同一套硬體上跑了兩次:一次是 linux-next 原樣,也就是 PREEMPT_LAZY,tps 平均 50,751.96;另一次把拿掉 PREEMPT_NONE 的那個 commit——7dadeaa6e851,標題是「sched: Further restrict the preemption modes」——回退回去,tps 平均 98,565.86,是前者的 1.94 倍。反過來說,新 kernel 只交出舊行為 0.51 倍的吞吐量。

其餘設定完全一致:同一顆 CPU、同一份 workload、同一組儲存,唯一的變因是排程器要不要在某個安全點多插一次搶佔的機會。這種決定,直覺上只該影響延遲分佈的尾巴,不該讓穩定狀態下的 tps 差到接近一半——這正是整篇追兇要解開的謎。

這個對照做得很乾淨:兩次測試除了那一個 commit 之外,kernel 版本、硬體、儲存、pgbench 參數全部相同,等於做了一次單變因實驗。1,024 個 client 疊在 96 個 vCPU 上、scale factor 8,470 的資料量,是刻意選來把並行度跟記憶體壓力都推到夠高的位置——如果只是把 client 數降低,或是資料庫小到能塞進 L3 快取,這個退化很可能根本壓不出來。能撐起這麼大差距的變因,如果不是搶佔模式本身,就是搶佔模式底下真正在動的某個東西。1,024 個 client 分攤到 96 顆 vCPU 上,平均一顆 vCPU 要扛超過十個 client——這種過度訂閱(oversubscription)本身就是刻意加重並行度的做法,不是多數服務日常會遇到的配置。

合理的推測是:12 顆 io2、32K IOPS 組成的 RAID0,用意是把 I/O 這個變數先墊高到不太可能成為瓶頸的水準,這樣觀察到的 tps 差異,才能乾淨地記在 CPU/排程器頭上,而不是讓人懷疑是磁碟不夠快。

設定 tps/結果 相對 baseline
linux-next(PREEMPT_LAZY,huge_pages=off) 50,751.96 1.00x
回退 7dadeaa6e851(PREEMPT_NONE 復原,huge_pages=off) 98,565.86 1.94x
linux-next(PREEMPT_LAZY,huge_pages=on) 未重現退化(Freund 後續測試) 不適用
benchmark 環境:m8g.24xlarge(96 vCPU Graviton4)、Amazon Linux 2023、PostgreSQL 17、pgbench simple-update、1,024 clients/96 threads、scale factor 8,470、fillfactor 90,每輪 1,200 秒取 3 輪平均。

懷疑一:只是搶佔次數變多,CPU 花在多餘的內容切換

最直覺的解釋是搶佔次數本身的成本:PREEMPT_LAZY 比 PREEMPT_NONE 允許核心在更多地方打斷使用者空間程式碼,每一次打斷都有內容切換的固定開銷,1,024 個 client 疊起來,開銷自然可觀。如果這個理論成立,多出來的 CPU 時間應該散布在整個呼叫路徑上——畢竟搶佔點理應到處都是,額外開銷也理當到處都在,不會集中在某一個特定函式裡。

這個第一直覺不是憑空猜的:每次搶佔通常伴隨暫存器狀態存檔、排程器重新選人,單次成本可能只是微秒等級,但 1,024 個 client 乘上高頻率的搶佔次數,理論上確實可能疊加成看得到的整體開銷。問題是「理論上可能」跟「這次就是」是兩回事,得靠 profile 去驗證,不能停在直覺。

驗證這個理論的工具是 perf:以固定頻率對所有 CPU 抽樣目前正在執行的呼叫堆疊,抽樣次數夠多之後,某個函式在樣本裡出現的比例,就約略等於它吃掉的 CPU 時間比例。如果額外開銷真的均勻灑在到處都是的搶佔點上,profile 應該長得平坦——很多函式各分到幾個百分點,沒有哪一個特別突出。

perf 給出的答案不是這樣。熱點集中在同一條呼叫路徑上:StartReadBuffer 佔 56.03% 的 CPU 時間,往下是 GetVictimBuffer 的 55.93%,再往下是 StrategyGetBuffer 的 55.93%,最後停在 s_lock,55.60%。四層幾乎是同一個數字一路往下傳,代表這不是分散在各處的搶佔開銷,而是幾乎全部的額外成本都卡在同一顆鎖的等待路徑上。搶佔次數變多這個理論,頂多解釋了「為什麼會被搶佔」,解釋不了「為什麼幾乎全部的成本收斂在這一條路徑」——這個理論被 perf 的數字排除了。

StartReadBuffer 56.03% GetVictimBuffer 55.93% StrategyGetBuffer 55.93% s_lock 55.60% StrategyControl->buffer_strategy_lock 這段程式碼在錯的設定下會觸發 minor page fault
四層呼叫路徑的 CPU 時間幾乎原封不動地往下傳,最後全部收斂在同一顆 spinlock 上。

懷疑二:那就是純粹的鎖競爭,跟記憶體怎麼分頁無關

下一個理論順理成章:既然熱點是 s_lock,這就是一顆在高並行下被搶著用的 spinlock,1,024 個 client 擠在 96 個 vCPU 上,鎖競爭激烈很正常,跟記憶體怎麼分頁、page size 訂多大應該沒有關係。Andres Freund 在 -hackers 郵件群組跟 Hacker News 上把這條線索繼續往下挖,得出的答案是:問題不是排程器本身,或者說不是直接的排程器問題。真正的機關在 StrategyControl->buffer_strategy_lock 這顆鎖保護的那段程式碼——在錯的設定底下,它會觸發一次 minor page fault。也就是說,這顆鎖之所以被拉長持有時間,不是單純因為競爭者多,而是持鎖期間本身就可能一腳踩進核心的缺頁處理路徑,跟純粹的鎖競爭理論不是同一回事。

要理解這顆 spinlock 為什麼會被拉長持有時間,得先看 StrategyGetBuffer 實際在做什麼:「Once the freelist drains, it runs the clock sweep, advances nextVictimBuffer, and returns a candidate.」——一旦 freelist 用完,它就會跑 clock sweep,把 nextVictimBuffer 這個指標往前推,回傳一個可以被覆寫的候選 buffer。這個候選 buffer 落在哪一頁,取決於 clock sweep 這一輪掃到哪裡;如果掃到的剛好是這個行程之前從來沒有真正碰過的一頁記憶體,對映到它的分頁表項目可能根本還沒建立,第一件要做的事,就是觸發一次 first-touch page fault——而這次觸發,恰好發生在鎖已經拿到手上的那一刻。

buffer_strategy_lock 是一顆 spinlock,不是一般的 mutex——這種鎖的設計假設臨界區間短到用忙等(busy-wait)比讓行程睡著、之後再被喚醒還划算。這個假設在臨界區間真的只有幾個指令時成立;一旦臨界區間裡摻進一次核心的缺頁處理,忙等的行程數量跟忙等的時間都會被放大,這正是 spinlock 這種設計最怕遇到的情況——鎖競爭這個表面現象是真的,但它是果,不是因。文章的說法更直接:「That access takes a minor page fault. The spinlock holder is now stalled in the kernel fault handler.」——持鎖的那個行程不是單純多睡了幾微秒,而是整個卡進核心的缺頁處理常式裡,沒辦法馬上把鎖交出去。

解法:把 3,100 萬次缺頁壓成 120 次

120GB 的 shared_buffers 在 4KB 分頁下,換算出來大約是 31,457,280 個 page table entry,也就是約 3,100 萬次潛在的 first-touch page fault——第一次真正碰到某一頁記憶體時,核心才會去建立對應的 PTE,這個動作要花上幾微秒。問題出在:持有 buffer_strategy_lock 的那個行程,如果剛好在缺頁服務期間被 PREEMPT_LAZY 排程器換下去,鎖的持有時間就不再只是「缺頁服務的幾微秒」,而是再加上一段排程延遲。文章形容得很精確:「Every other backend — dozens or hundreds of them on a 96-vCPU box under 1,024 clients — is spinning in userspace, burning CPU, waiting.」——96 顆 vCPU、1,024 個 client 的機器上,等著拿同一顆鎖的可能是幾十到幾百個 backend,這段時間全部困在 userspace 裡空轉燒 CPU、乾等,這正是 s_lock 吃掉 55.60% CPU 時間的來源,也是為什麼一個看似只影響搶佔時機的排程器改動,會讓穩定狀態的 tps 腰斬。

把分頁大小拉大,first-touch page fault 的次數就跟著往下砍:換成 2MB huge pages,同一塊 120GB 對映需要的 PTE 降到 61,440 個;換成 1GB huge pages,降到只剩 120 個。開著 huge_pages(或是 THP 剛好判斷正確)的機器上,這個退化完全沒有重現;同一套硬體只要把 huge_pages 關掉,退化就會出現。Freund 後續的測試證實了這個對應關係——變因只有 huge_pages 這一個開關。

照這個倍數推算:2MB huge pages 比 4KB 分頁的 PTE 數量少了整整 512 倍(31,457,280 ÷ 61,440),1GB huge pages 更是少了 262,144 倍(31,457,280 ÷ 120)。這兩個倍數不是文章直接給的結論,是從已知的 PTE 數字反推出來的——但方向很清楚:分頁越大,first-touch page fault 撞上鎖持有時間的機率,就跟著等比例往下掉。

這組數字背後的道理很直接:分頁越大,同一塊 120GB 需要對映的分頁數就越少,第一次觸碰而觸發缺頁的機會也跟著等比例下降。這不是 Postgres 特有的行為,是任何在 Linux 上用 mmap 建立大塊共享記憶體的軟體都會踩到的物理限制,差別只在於多數軟體的常駐工作集不會在高並行下同時衝進大量尚未觸碰的分頁——96 vCPU、1,024 個 client 同時打 shared_buffers 的場景,剛好把這個限制的代價放到最大。THP(transparent huge pages)理論上能在不改設定的情況下自動把普通分頁合併成大分頁,但「試著用」跟「保證用」是兩回事,這也是為什麼設定上要明確把 huge_pages 設成 on,而不是依賴預設的 try 模式。

分頁數量往下砍,帶來的好處不只是少踩幾次 first-touch page fault。文章補了一句常被忽略的細節:「TLB pressure drops by four to six orders of magnitude, so the buffer-manager hot path actually stays hot in the TLB instead of thrashing it.」——換成 huge pages 之後,TLB 的壓力會跟著下降四到六個數量級,buffer manager 那條熱路徑因此能穩定留在 TLB 裡,不必每次位址轉換都重新查表、把 TLB 沖掉又填回來。缺頁次數是這次退化的直接觸發點,TLB 命中率則是 huge pages 額外多賺到的一層好處,兩者疊在一起,才是「開了 huge_pages 退化就完全消失」背後真正發生的事。

這也是一次乾淨的反向驗證:如果懷疑二成立,也就是熱點單純是高並行下的鎖競爭、跟分頁大小無關,那麼同一個 PREEMPT_LAZY kernel,開不開 huge_pages 應該都會出現退化——競爭者數量並沒有變。但事實是開了 huge_pages 退化就消失,關掉就出現,唯一變因只有分頁大小。這個對照直接把「純鎖競爭」的理論排除掉,留下的解釋只剩缺頁次數乘上鎖持有時間。

點選分頁大小看 first-touch page fault 數量 · 3 個選項

1 100 10,000 1,000,000 100,000,000 first-touch page fault 數(log) 31,457,280 4 KB 61,440 2 MB 120 1 GB

4 KB

31,457,280(~31M)

120GB ÷ 4KB ≈ 31,457,280

退化:有

同一塊 120GB shared_buffers,換算成三種分頁大小下的 PTE/first-touch page fault 數量。數字來自 Dipietro/Freund 對同一套硬體的計算,不是連續內插。

同一個問題其實有兩條可能的修法,一條在應用層,一條在 kernel 層,值得放在一起比較。

設定 huge_pages = on(不是 try),搭配 sysctl -w vm.nr_hugepages=<N>postgres -D $PGDATA -C shared_memory_size_in_huge_pages 算出需要的頁數,設定完後可以用 grep -E 'HugePages_Total|HugePages_Free' /proc/meminfo 確認頁數真的分配成功。這是已經驗證有效的修法:同一套硬體,開了 huge_pages 退化就消失,Freund 的後續測試證實了這一點。代價是要事先規劃記憶體,不能等服務起來才臨時加。
讓持有 buffer_strategy_lock 的行程在缺頁服務期間變成不可搶佔,例如借助 rseq 之類的機制,從 kernel 這邊直接堵住問題。Freund 對這個方向態度保留:要求使用者空間軟體去採用一個 7.0 之後才新增的核心設施,只是為了掩蓋一個同樣在 7.0 之後才出現的退化,這筆帳他認為不划算。Postgres 未來會不會往這個方向走,作者自己的判斷是「大概會」,但明確標成推測,不是定論。

rseq(restartable sequences)原本是設計來讓 userspace 程式碼在一小段「不能被打斷」的臨界區間裡執行——如果真的被搶佔,核心會把它重新導回起點,而不是讓臨界區間看起來被打斷到一半。具體的提案內容是:「have Postgres use rseq-based time-slice extensions to tell the scheduler 'please don't preempt me right now'」——也就是讓持鎖的那個行程,能在缺頁服務前主動跟排程器打一聲招呼,而不是被動等著被打斷。要把這個機制套進 Postgres 的緩衝區管理器,等於是為了一個只存在於 7.0 之後的退化,去採用一個同樣只存在於 7.0 之後的新設施,這也是 Freund 保留態度的核心理由。作者對 Freund 這個立場的評語是:「Freund's response was measured and exactly right」——把這句判斷讀作 Pettus 自己的背書,而不是 Freund 本人的原話。作者原文用的字是:「Will Postgres eventually do something like it? Probably.」——這個「Probably」標記得很清楚,是作者自己的推測,不是 Freund 或 Postgres 社群的正式承諾。

合理的推測是,任何用大塊 shared memory 段、且在高並行下大量觸碰新分頁的軟體——不只是 Postgres——都可能踩到同樣的耦合,這不是資料庫特有的問題,是分頁大小跟搶佔策略之間一個容易被忽略的交叉點。

4 月 3 日 Dipietro 把 benchmark 與 revert patch 貼上 LKML 4 月 15 日 Lætitia Avrot 在 My DBA Notebook 發表獨立走查 4 月 23 日 Christophe Pettus 發表 本文所本的追兇紀錄
這場追兇公開發生:LKML 討論串、獨立部落格、再到把整條推理寫成一篇文章,前後不到三週。

回到文章標題丟出的問題:一般工程師的 Postgres,真的需要為 PREEMPT_NONE 消失而緊張嗎?作者給的答案很直接——「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.」幾乎不用。這個退化是真的,perf 的數字也是真的,但它是一個範圍很窄、聲量很大的產物,源頭是一套原本就沒設定好的環境:120GB 的 shared_buffers 開著,huge_pages 卻是關的。多數線上環境要嘛 shared_buffers 沒有這麼大,要嘛本來就開了 huge_pages,這兩個條件同時不成立、又疊上 1,024 個 client 這種並行度,才會把這條路徑的成本放大到看得見的地方。工程師真正該問的兩個問題,是 shared_buffers 開多大、huge_pages 有沒有開——這兩個答案,決定的是會不會踩上這條路徑,而不是這次 kernel 升級本身安不安全。

把整條因果鏈串起來看:kernel 拿掉 PREEMPT_NONE、換上 PREEMPT_LAZY,讓持鎖行程有可能在缺頁服務期間被換下 CPU;StrategyGetBuffer 的 clock sweep 又剛好會踩到還沒建立 PTE 的新分頁,觸發 first-touch page fault;120GB shared_buffers 在 4KB 分頁下有 3,100 萬次這樣的潛在觸發點;每一次觸發疊上排程延遲,鎖的持有時間就被拉長,幾十到幾百個 backend 跟著在 userspace 空轉——四個環節疊在一起,才是 tps 腰斬的完整成因,缺一個環節,這個退化都不會發生到看得見的程度。文章給前線工程師的操作建議濃縮成三個動作:「Set huge pages. Upgrade your kernel. Move on.」——開好 huge pages、照常升級 kernel、然後不用再為這件事分心。這句話某種程度上也是整篇追兇的立場:它把責任放回維運設定,而不是要求 kernel 社群回頭改變搶佔模式的預設值。

Next time:kernel 的排程器預設值變了、下游東西跟著變慢,先別急著懷疑搶佔次數或內容切換這種一般性開銷——去查大型 shared memory 區段用的是哪一種分頁大小,缺頁次數乘上鎖持有時間,往往比排程器本身更值得懷疑。huge pages 開了沒,是升級前就該確認的事,不是等 tps 腰斬之後才回頭補的動作——一行 grep /proc/meminfo 就能先確認,成本比等到退化發生後再排查低得多。