Skip to content
Published on

300倍はPostgreSQLをチューニングして出た数字ではない — 火山モデルとベクトル化実行

シェア
Authors

はじめに — 見出しの主語を確認するのに30秒

「PostgreSQLを分析クエリで300倍速くした」という記事がタイムラインに回り始めたら、まず確認すべきなのは300という数字ではなく文の主語です。

原文はmalisper.meに2026年8月3日に上がったRebuilding Postgres for 300x faster analyticsで、最初の段落はこう始まります。「先週pgrust 0.2をリリースした。」

pgrustはPostgreSQLをRustで作り直した別個のデータベースです。ワイヤプロトコルとSQL方言が互換で回帰テスト46,066個をすべて通過しますが、リポジトリのREADMEの最初の行に「プロダクション準備ができていない、大切なデータを入れるな」と書かれています。つまり300倍は、皆さんが今日運用しているPostgreSQLの設定ファイルを直して得られる数字ではありません。

だからといってこの記事に読む価値が無いという意味ではまったくありません。むしろ逆です。著者は300倍のうちクエリエンジンが寄与した約10倍分を、誰でも再現可能な60行のRustコードに分解して見せてくれます。その分解がこの記事の本当の中身であり、私たちが学ぶべきものもそれです。

300倍と10倍は互いに別の実験だ

混同を防ぐために二つの数字をまず分離します。

300倍はClickBenchのスコアです。READMEによるとc8g.4xlarge(AWS Graviton4)でPostgreSQL 18.3を相手に測定し、pgrust独自のカラム型ストレージフォーマットであるpgrcolumnarを使いました。同じベンチマークでClickHouseより18.5パーセント速かったとも書かれています。

9.6倍はブログ本文のSUMクエリの実験です。これはpgrustとまったく関係のない、純粋なRustコード四種類を比較した結果です。

著者がREADMEに自ら書いておいた但し書きも一緒に読まなければなりません。ベンチマークのビルドはGraviton4向けに-Ctarget-cpu=neoverse-v2のチューニングを経ており、配布されるバイナリはそうではないので「ダウンロードしてもそのまま再現されない」と明示します。JITコンパイラもGraviton4だけを対象とします。Kubernetesではむしろ OLTPの差がもっと大きく(50〜60パーセント)出たものの、原因を突き止められなかったので低いほうの数字である30パーセントを引用する、とも述べています。

ベンチマークの記事を読むとき、ここまで自分の条件を書いておく場合はまれです。その誠実さをそのまま受け取って引用するのが礼儀です。

20秒かかるSUMクエリの正体

著者が出発点にしたクエリはこれです。

CREATE TABLE my_table AS
SELECT col::float8 FROM generate_series(1.0, 500000000.0) g(col);

SELECT SUM(col) FROM my_table;

測定条件はc8g.4xlarge、PostgreSQL 18.4、max_parallel_workers_per_gather = 0、データは共有バッファに乗った状態、5回の中央値です。この条件で約20秒かかりました。

同じ5億個のf64をRustの単純なforループで足すと358ミリ秒です。55倍の差ですが、著者はすぐさま「これは同等の比較ではない」と釘を刺します。PostgreSQL側にはロック処理やストレージフォーマットのパース、タプル抽出といった仕事が余分にあるからです。

ここで並列クエリを切ったことも重要です。実際に運用しているPostgreSQLならmax_parallel_workers_per_gatherが既定で有効なので、このクエリはワーカー複数に分かれます。著者がこれを切った理由はクエリエンジン自体の単一コア効率を見るためであって、PostgreSQLを不利にするためではありません。ただしこの数字を引用するときは条件も一緒に引用しなければなりません。

火山モデル — 行ひとつに関数呼び出しひとつ

PostgreSQLの実行器は火山モデル(Volcano model)を使います。計画ノードごとにnext()があり、一度呼ぶと行がひとつ出ます。ルートでnext()を呼び続けるとクエリが終わります。著者が作った縮小版はこうです。

trait Node {
    fn next(&mut self) -> Option<f64>;
}

struct SeqScan<'a> { table: &'a [f64], pos: usize }

impl Node for SeqScan<'_> {
    fn next(&mut self) -> Option<f64> {
        if self.pos >= self.table.len() { return None }
        let value = self.table[self.pos];
        self.pos += 1;
        Some(value)
    }
}

この構造の長所は明確です。ノードごとにメソッドをひとつ実装すればよく、どのノードもどのノードの上に載せられます。PostgreSQLが40種を超える計画ノードを管理しながら組み合わせ爆発を起こさない理由です。

問題はコストです。5億行ならnext()が5億回呼ばれます。しかもBox<dyn Node>の裏に隠れた呼び出しは実行時になってはじめて対象が決まる間接呼び出しなので、分岐予測とインライン化がうまく効きません。この縮小版が1.3秒、forループが358ミリ秒。差の大半がこの呼び出しオーバーヘッドです。

バッチ — 呼び出し回数を1024で割る

最初の最適化はバッチです。next()が行ひとつの代わりに1024個の配列を埋めて返すように変えます。

const BATCH: usize = 1024;

trait BatchNode {
    fn next_batch(&mut self, out: &mut [f64; BATCH]) -> usize;
}

呼び出し回数が5億から約49万に減り、時間は1.3秒から約480ミリ秒になります。2.7倍です。

著者が押さえるディテールがひとつあり、これが実務的にはもっと重要です。バッチバッファをスタックに取ったという点です。[0.0f64; BATCH]はヒープ割り当てではないので、集約ノードは実行中にメモリをまったく割り当てません。バッチ単位の実行に変えながらバッチごとにVecを新しく作ると、減らした関数呼び出しのコストを割り当てコストとしてそのまま返してもらうことになります。

演算子融合 — そして著者が自ら「反則」と呼んだ地点

バッチ版をプロファイルすると今度はcopy_from_sliceがホットスポットです。スキャンがバッファへコピーし集約がそのバッファを読む構造なので、中間コピーが残っています。

演算子融合はスキャンと集約をノードひとつに合わせてこのコピーを無くします。結果は358ミリ秒、ちょうどforループと同じです。当然です。合わせてしまえば文字どおり同じコードになるからです。

著者はここで自らブレーキをかけます。原文の表現で「これは反則のように見えるかもしれないし、実際に反則だ」と書きます。どんなクエリが来るかをあらかじめ知っていて、その組み合わせだけをハードコードしたからです。よくある組み合わせをいくつか先に用意しておくことには意味がありますが、用意できなかった組み合わせはすぐに現れます。

その一般解がJITコンパイルです。クエリを受け取ったあとそのクエリにぴったり合った機械語を生成すれば、すべてのクエリで「反則」できます。pgrustが実際に使っている方式であり、本文では扱わず次の記事に回しています。

SIMD — コンパイラが勝手にやってくれない理由

最後がSIMDです。aarch64 NEONでアキュムレータを4個置き、チャンクごとに8個ずつ足します。

use std::arch::aarch64::*;
let mut acc = unsafe { [vdupq_n_f64(0.0); 4] };
let (chunks, rest) = self.table.as_chunks::<8>();
for chunk in chunks {
    for lane in 0..4 {
        unsafe {
            let v = vld1q_f64(chunk.as_ptr().add(2 * lane));
            acc[lane] = vaddq_f64(acc[lane], v);
        }
    }
}

135ミリ秒。forループよりも2.7倍速く、最初の火山モデルに対しては9.6倍です。

なぜコンパイラが勝手にこの変換をしてくれなかったのでしょうか。著者が明示的に答えます。浮動小数点の加算は結合法則が成り立たないからです。アキュムレータを4個に割ると足す順序が変わり、結果が最後のビットで変わりうるのです。コンパイラは基本的にその変換を許しません。著者がこの例を選んだ理由も、コンパイラがこっそりベクトル化して実験を台無しにしないようにするためです。

整数の合計であればコンパイラが勝手にベクトル化していたはずで、この段階の利得ははるかに小さく見えたでしょう。ベンチマークの設計が結果を作るという良い例です。

表が語ること、そして今日できること

実装時間倍率
PostgreSQL 18.4約20秒
火山モデル1.3秒1倍
バッチ追加480ミリ秒2.7倍
演算子融合追加358ミリ秒3.6倍
SIMD追加135ミリ秒9.6倍

この表の左端と右端は性格が違います。1.3秒から135ミリ秒までは、同じ言語、同じプロセス、同じデータ構造を置いて実行戦略だけを変えた結果です。20秒から1.3秒までは、ストレージフォーマット、ロック、MVCCの可視性判定、タプルのデシリアライズといったものが丸ごと抜けた結果です。前者は実行エンジンの設計の話であり、後者は「データベースがデータベースであるがゆえに払うコスト」の話です。

だからこの記事を「PostgreSQLが遅い」とまとめると半分は間違いです。正確には、1980年代にディスクI/Oをボトルネックと仮定して設計された実行器が、データがすべてメモリに載る2026年の分析ワークロードでCPUボトルネックを露わにするという話です。著者も冒頭でまさにそう書いています。

では、pgrustを使えない今、同じ原理をどこまで使えるでしょうか。以下の項目は原文に出てこない別個の話なので、それぞれ自分のバージョンの文書で既定値と対応状況を確認してから使ってください。

第一に、並列クエリを切らないでください。上の実験はわざと切りましたが、実際の集約スキャンでワーカーを増やすことは依然としてもっとも手軽な倍率です。計画にGatherが見えるか、ワーカーが何個ついたかから確認します。

EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT SUM(col) FROM my_table;
-- Workers Planned / Workers Launched が実際に何個かを見る

第二に、行数を減らすほうが行あたりのコストを減らすほうより、ほぼ常に大きいです。火山モデルのオーバーヘッドは行数に比例するので、事前集約テーブルや部分インデックスでスキャン対象の行そのものを減らせば、上の表のすべての段階を一度に飛ばせます。

第三に、カラム指向のストレージが必要なら、それをやってくれる拡張を使うほうがよいです。ただし拡張の性能主張についても、この記事がしたように「何を、どのハードウェアで、どの設定で」測ったのかを確認して引用してください。それがこの記事から持ち帰るもっとも実用的な習慣です。

参考資料