5 億筆浮點數的加總,一個照著 Volcano 模型寫的迷你查詢引擎要跑 1.3 秒。malisper 在上面動了三刀,沒有換演算法、也沒有換一份更快的資料,時間掉到 135 毫秒。前兩刀拿掉的是直譯器逐行呼叫的稅,第三刀連裸寫的 for 迴圈都甩掉將近 3 倍。
pgrust 的十倍:批次、融合、SIMD
pgrust 是一個用 Rust 寫的資料庫專案,文中一路拿它跟 Postgres 跑同一組查詢與基準。上週發布的 0.2 版本,headline 數字有三個:比自己上一版快 10 倍、OLTP 查詢比 Postgres 快 30%、在 ClickHouse 的分析型基準 ClickBench 上比 Postgres 快 300 倍——甚至贏過 ClickHouse 本身。查詢引擎的改動單獨就貢獻了 300 倍裡大約 10 倍,而它的做法沒有黑魔法:拿一個迷你版的 Volcano 模型查詢引擎,對著同一個查詢、同一份 5 億筆浮點數的資料表,一層一層疊加同樣三個優化,讓讀者看著每一刀砍掉多少時間。整篇文章只圍繞一個查詢——SELECT SUM(col),刻意選了一個磁碟幾乎不會成為瓶頸的場景,好讓查詢引擎本身的開銷單獨露出來。
點或觸碰任一數字看它在跟誰比 · 4 個數字
OLTP 那 30% 的提升,跟這篇文章講的三刀不算同一件事——批次化、算子融合、SIMD 全部是針對「掃很多列、做同一種運算」的分析型查詢設計的優化,OLTP 查詢的典型模式是少量列、多次隨機存取,批次跟 SIMD 幫得上忙的機會小得多。合理的推測是那 30% 主要來自 pgrust 別的部分——像是更輕量的鎖或交易實作——而不是這篇談的查詢引擎;文章沒有把兩者拆開講清楚,這裡只能是推測。
磁碟年代的設計
為什麼還有這麼多空間可以壓榨?作者的說法是 Postgres 誕生在一個磁碟 I/O 主導效能的年代——最初的 Postgres 專案可以追溯到 80 年代,那時候資料庫效能的瓶頸幾乎都是磁碟。三個趨勢讓這個假設不再成立:資料集現在常常整個放得進記憶體,磁碟 I/O 大部分被消掉;分析型工作負載動輒大量掃描,瓶頸常常不再是磁碟吞吐量,而是 CPU 或記憶體頻寬;就算資料放不進記憶體,NVMe 近年也比傳統硬碟快上百倍。換句話說,直譯器逐行呼叫的開銷在磁碟時代是誤差範圍內的雜訊——反正大部分時間都在等磁碟;資料一旦整批搬進記憶體,這個開銷就從背景雜訊變成主角。這就是查詢引擎這三刀要打的東西:不是換更聰明的演算法,是把 Postgres 從 80 年代留下來的直譯器結構,一層一層拆掉。
這個框架不是 pgrust 獨有的觀察——任何一個把資料集整個放進記憶體、或者跑分析型大量掃描的系統,都會撞上同一個問題:不是磁碟慢,是直譯器本身的每一步都要付一次代價。這篇文章接下來要走的,是這個代價具體長什麼樣子,以及要拆掉它需要付出什麼工程成本。
Volcano 模型:一次一列的直譯器
作者示範用的是一個迷你版查詢引擎,遵循 Postgres 沿用至今的 Volcano 模型:每個 plan node 實作一個 next(),工作是回傳單一列——SeqScan 的 next() 回傳循序掃描的下一列,聚合節點的 next() 則是把整個聚合算完才回傳單一列結果。跑一個 SELECT SUM(col) FROM my_table,資料表是用 generate_series(1.0, 500000000.0) 灌出來的 5 億筆 float8,SumAggregate 節點透過 child.next() 一列一列跟 SeqScan 要資料——沒有批次化,SeqScan.next() 每一列呼叫一次,函式呼叫次數等於列數。這個基線版本跑起來要 1.3 秒。同一個查詢丟進真正的 Postgres 18.4(關掉平行 worker、資料已經預熱在 shared buffer 裡)要跑約 20 秒——比這個迷你直譯器基線還慢十幾倍。這個落差不是本文重點,本文接下來要講的是這 1.3 秒怎麼一路被砍到 135 毫秒。
這種一次只吐一列的設計,好處是節點之間完全解耦——排序節點不需要知道底下是循序掃描還是索引掃描,只要呼叫 next() 就好,合理的推測是,這也是 Volcano 模型撐得住 Postgres 長年演進、支援任意查詢計畫組合的原因。同一套 next() 介面在 Postgres 裡不只被拿來做循序掃描和聚合——排序、雜湊 join、索引掃描、filter,全部都實作同一個介面,規劃器可以把任何一種組合串成一棵樹,不用管樹上其他節點內部長什麼樣子。原文只說 Volcano 模型的優點是「很簡單」;合理的推測是,這種可組合性也是它一路撐到現在的原因,也是為什麼作者選擇先示範一個最簡單的 scan 加 sum,而不是直接處理更複雜的計畫樹。壞處是每一列都要走一次完整的函式呼叫路徑,5 億列就是 5 億次呼叫,就算每次呼叫只有幾奈秒的固定開銷,乘上 5 億次也會疊成看得見的秒數。真正的 Postgres 跑同一個查詢要 20 秒,比這個迷你直譯器基線的 1.3 秒還慢十幾倍,差距顯然不只是直譯器開銷——原文點名 Postgres 兩個最大的開銷來源是鎖,以及解析 Postgres 儲存格式、從中抽出查詢用得到的 tuple,這些不是本文的範圍,本文只把鏡頭對準查詢引擎那一段。資料表本身也刻意做得很單純:用 generate_series 產生的欄位是均勻遞增的浮點數,沒有 null、沒有變動長度的字串、沒有索引可以抄捷徑——SUM 這個聚合函式必須把每一列都摸過一次,這保證了查出來的時間差全部來自查詢引擎,而不是資料本身的怪癖。
| 項目 | 數值 |
|---|---|
| 硬體 | AWS c8g.4xlarge(Graviton4,16 vCPU) |
| Postgres 設定 | 18.4,max_parallel_workers_per_gather = 0 |
| 資料量 | 5 億筆 float8,SELECT SUM(col) |
| 量測次數 | Postgres 取 5 次中位數;Rust 各版本各跑 4 次 |
| 真實 Postgres 耗時 | 約 20 秒 |
| 查詢引擎三刀後 | 135 毫秒 |
| 查詢引擎對 300 倍的貢獻 | 約 10 倍 |
量測方法本身值得看一眼:Postgres 端取 5 次的中位數,Rust 端每個版本各跑 4 次,全部在同一台機器、同一個 process 內完成——這是部落格等級的基準測試,run 數不多,不是嚴謹的統計研究,但作者把硬體、Postgres 版本、平行度設定都寫得很清楚,讀者至少可以照著同樣的設定重跑一遍。真正的 Postgres 測試特別關掉了 max_parallel_workers_per_gather,也就是關掉平行掃描——這代表 20 秒是單執行緒 Postgres 的數字;迷你查詢引擎這邊的四個版本同樣沒有用到多執行緒,比較的基準是一致的。作者還特別提了一句:pgrust 在 ClickBench 上甚至贏過 ClickHouse——一個原生列式儲存、專門為分析設計的資料庫。查詢引擎這三刀砍出來的效能,某種程度上是在說:只要把直譯器開銷砍到接近零、資料掃描做到接近硬體極限,通用型資料庫在分析查詢上追上專門的分析引擎,不是不可能的事。當然,ClickBench 是單一基準,不代表所有分析工作負載都會有同樣的結果。
下面這張圖是接下來要走過的全貌:同一個查詢,四個版本,時間怎麼一路往下掉。拖動滑桿看每一刀砍掉了什麼。
拖動滑桿切換四個版本 · 4 個階段
批次化:next_batch 與堆疊緩衝區
第一刀是最直覺的——用 next_batch() 取代 next()。它不是一次回傳一列,而是把整批列寫進呼叫端提供的緩衝區,回傳實際填入的列數:fn next_batch(&mut self, out: &mut [f64; BATCH]) -> usize,批次大小 BATCH 是程式碼裡的常數 1024。這個做法把函式呼叫次數從「一列一次」除以 1024,直譯器逐行呼叫的固定開銷就被攤薄了。作者特別點出一個細節:批次緩衝區配置在堆疊上,不是每批次都跟堆積要一塊新記憶體——對一個要跑五億次的內迴圈,任何一次額外的堆積配置都是可以省下來的成本。批次化這一刀單獨就把時間從 1.3 秒砍到約 480 毫秒,效果已經吃掉了大部分開銷。
為什麼特別強調緩衝區配置在堆疊上?作者給的理由很直接:配置記憶體本身是相對慢的操作,寫追求極致速度的程式碼就要盡量減少配置次數——如果批次緩衝區改成每批次向堆積要一次記憶體,1024 列才攤一次的呼叫開銷,很可能又被配置開銷吃回去。堆疊配置不用經過配置器,函式一進入就地備好、退出就地收回,這是批次化這一刀能把時間壓到 480 毫秒而不是打了折扣的原因之一。至於為什麼批次大小挑 1024 而不是 128 或 8192,文章沒有解釋,合理的推測是要在兩件事之間找平衡:批次要夠大才能把逐行呼叫的固定開銷攤得夠薄,但也不能大到讓緩衝區塞不進 CPU 的一級資料快取——1024 個 f64 剛好是 8 KB,跟常見一級快取的大小同一個量級。
算子融合:兩個節點焊成一個
批次化之後,新的效能熱點不再是逐行呼叫,而是搬資料本身——profiler 顯示熱點變成 copy_from_slice:即使已經在批次處理,SeqScan 掃出來的資料還是得先複製進緩衝區,SumAggregate 才能讀。解法是「算子融合」:把 SeqScan 跟 SumAggregate 焊成一個節點,中間那次複製直接消失,兩個階段變成同一段迴圈。效果立竿見影——融合後的節點跑 358 毫秒,跟一段裸寫、完全不經過任何 Volcano 抽象的 for 迴圈一模一樣快;作者給的理由很直白:編譯出來的組合語言本來就是同一段程式碼。這一刀的代價是通用性:文中示範的引擎手刻的是 scan 加聚合這一種組合,不是任意兩個算子都能自動融合,作者說 pgrust 怎麼運用 JIT compilation 得改天再談,這篇沒有展開。
融合兩個節點聽起來像是把邏輯攤開的簡單重構,但代價在別處:照這個做法,每一種「值得手刻」的算子組合都要各寫一個融合節點,組合數隨算子種類成長很快就會失控——scan 加 sum 只是最簡單的一種,換成 scan 加 filter 加 count、或者 join 加聚合,都得各自手刻一次。合理的推測是,這也是作者說 JIT compilation 得改天再談的原因:與其窮舉所有組合手刻融合節點,不如在查詢規劃階段直接把整條計畫編譯成一段機器碼——理論上可以拿到跟手刻融合一樣的效能,同時不用為每種組合寫一次特例。這篇文章展示的是手刻能做到多快,作者自己也承認這不是終點。
三刀的順序也不是隨便排的。原文記錄到的一次 profiling 發生在批次化之後,量出來的熱點是 copy_from_slice;合理的推測是每一刀動手前都走過同一步,先量出當下真正的熱點:批次化消掉了逐行呼叫的熱點之後,新的熱點才輪到 copy_from_slice;複製消掉之後,剩下的才是純算術,這時候上 SIMD 才划算,因為前面兩刀已經把非計算的開銷清乾淨,CPU 的運算頻寬才是唯一還沒榨乾的資源。如果順序反過來,先上 SIMD 再談批次化,向量化省下來的那幾奈秒很可能直接被逐行呼叫的固定開銷蓋過去,感覺不出差別。這種「量測、找熱點、動一刀」的節奏,本身也是效能工程常見的做法,不是 pgrust 這篇獨有。
播放看兩種呼叫節奏的差別 · 2 段對照
上排的點用 steps() 這個 easing 卡格前進,下排的點用 linear 平滑滑過——卡格對應的是 next() 每叫一次就要停一次的節奏,平滑對應的是融合後單一迴圈一路掃到底的節奏。畫面上跳了 14 格不是真的呼叫了 14 次,實際是 5 億次,這裡純粹是把「有沒有中途停頓」這件事具象化。
SIMD:NEON 的四個累加器
最後一刀是 SIMD——單一 CPU 指令同時對多筆資料做同一個運算,通常比逐列處理快得多。文中示範的引擎用的是 ARM AArch64 的 NEON intrinsics(use std::arch::aarch64::*),程式碼宣告了一個長度 4 的累加器陣列([vdupq_n_f64(0.0); 4]),對應四個各自獨立、初始化為 0 的 128-bit NEON 暫存器;輸入資料以每 8 個 f64 為一組切塊(as_chunks::<8>()),用 vld1q_f64 載入、vaddq_f64 做向量加法,四個累加器各自吃一部分資料,迴圈結束後再用 vaddvq_f64 把四個累加器的值疊成單一總和。文章沒有明講為什麼要四個累加器而不是一個——合理的推測是為了打斷加總運算本身的資料相依鏈:只有一個累加器意味著每次加法都要等前一次加完才能做下一次,四個各自獨立的累加器讓 NEON 執行管線可以同時處理多筆加法而不必互相等待。這一刀把時間壓到 135 毫秒,比裸 for 迴圈還快將近 3 倍,是最初 Volcano 版本的 10 倍。
這段程式碼是針對 AArch64 寫的——測試機器是 AWS c8g.4xlarge,用的正是 ARM 的 Graviton4,這也是為什麼作者選 NEON 而不是 x86 的 AVX。NEON 的一個 128-bit 暫存器可以裝兩個 f64,四個累加器合起來一次處理 8 個值,這是「四個累加器、每次迭代處理 8 個值」這句話背後的算術。如果指令集換成能裝更寬暫存器的 AVX-512,同樣的邏輯理論上可以一次處理更多值——但文章測的是 Graviton4,沒有給出 x86 上的對照數字。三個 intrinsic 各自的分工也很直白:vld1q_f64 是「把記憶體裡一段連續資料載入成一個 128-bit 向量」,vaddq_f64 是「兩個向量逐元素相加」,迴圈跑完之後用一次 vaddvq_f64,把四個累加器橫向加總成一個純量——搬資料、平行加、收斂成一個數字,剛好對應 SIMD 化前那個單純 for 迴圈裡的三個步驟。
下面這張圖把四個版本疊成結構,而不是時間軸——由後往前疊,分別對應剛才這三刀的順序。
文章結尾把同一組數字換了一種呈現方式——不是毫秒,是相對 Volcano 基線的累計倍數:批次化 2.7 倍、加上算子融合 3.6 倍、加上 SIMD 9.6 倍。作者自己的總結很平實:三個簡單的優化,讓查詢快了 10 倍。300 倍裡剩下沒被這三刀解釋到的部分,大約還有 30 倍——文章只說還有「許多其他」優化,沒有點名這 30 倍從哪裡來;合理的推測是儲存格式、交易層或別的子系統的改動,但那些不在這篇的範圍內,作者也沒有藉這篇文章一併交代。
這三刀砍掉了什麼:Volcano 模型本身沒有錯,它把查詢計畫拆成可以任意組合的節點,這個彈性是 Postgres 撐過四十年的原因之一;三刀真正打掉的,是「彈性」被實作成「每列一次函式呼叫、每個節點一次記憶體複製、每次加法一個純量指令」之後產生的稅。批次化攤薄呼叫次數,算子融合消掉複製,SIMD 讓一個指令做八個人的活——三層疊起來,同一段邏輯從 1.3 秒的直譯器變成 135 毫秒、跟手寫組合語言差不多快的迴圈。剩下沒打的那塊,作者只留下一句改天再談:pgrust 怎麼運用 JIT compilation,讓算子融合不必停在手刻特例這個通用機制。對照本文開頭那 20 秒的真實 Postgres,這三刀真正負責的是 1.3 秒到 135 毫秒那一段,9.6 倍;20 秒到 1.3 秒是另一組比較,原文把它歸給鎖與解析 Postgres 儲存格式這兩大開銷,不是這三刀拿掉的東西——所以真正的收穫不是那個總倍數,而是逐行直譯器被一層一層拆掉之後,剩下的就只是資料本身的大小。如果手上的 Postgres 服務也是把整個資料集放進記憶體跑分析查詢,這三個問題一樣值得先問一輪:查詢是不是還在一行一行處理、節點之間是不是還在互相複製、CPU 該用的向量指令是不是還沒用上。