PG-StromはNVIDIA GPUとその周辺技術、Apache Arrowを使ってPostgreSQLのクエリー処理を高速化を実現するPostgreSQLの拡張機能技術です。このブログでも何度か取り上げています。 先日ちょっと面白い挙動を示したので、こちらのブログで共有しようと思いました。
まずは準備
PG-Stromのインストールについては、公式のマニュアル に従いました。 その上で、次にようなテーブルを作ってEXPLAIN ANALYZEを実行しました。
CREATE TABLESPACE nvme LOCATION '/opt/nvme'; CREATE DATABASE testdb TABLESPACE nvme; \c testdb CREATE extension pg_strom; CREATE TABLE t_test AS SELECT id, id % 10 AS ten, id % 20 AS twenty FROM generate_series(1, 25000000) AS id ORDER BY id; CREATE TABLE t_join AS SELECT * FROM t_test ORDER BY random() LIMIT 1000000; VACUUM ANALYZE t_test; VACUUM ANALYZE t_join; EXPLAIN ANALYZE SELECT count(*) FROM t_test AS a, t_join AS b WHERE a.id = b.id GROUP BY a.ten;
このコードはPG-Stromをインストール後の初期動作検証でよく実行するコードです。 これだけ大きいデータをCPUで通常処理するのはなかなか大変です。
GPUとCPUでパフォーマンス比較してみる
すると、次のような感じになりました。統計情報を見ると、GPUを使って処理されるような実行計画になっており、処理に要する時間もCPU処理よりも高速になりそうという試算になっています。ちなみにこれを試した環境よりももっと新しいGPUであれば、もっと速くなりますし、GPUDirect Storageガ使える環境であればよりパフォーマンスが向上します。PG-Stromのライセンスがあれば、複数のストレージをサポート*1するので、オンプレでPG-Stromを実行する場合はNVMe SSDを束にできるので、よりパフォーマンスが向上します。
この結果はEXPLAIN ANALYZEした結果ではありますが、経験上実際実行しても、ほとんど同じくらいのクエリータイムで処理してくれます。
testdb=# EXPLAIN ANALYZE SELECT count(*) FROM t_test AS a, t_join AS b WHERE a.id = b.id GROUP BY a.ten; QUERY PLAN ---------------------------------------------------------------------------------------------------------------------------------------------- Gather (cost=83721.66..83722.71 rows=10 width=12) (actual time=894.680..899.562 rows=10 loops=1) Workers Planned: 2 Workers Launched: 2 -> Parallel Custom Scan (GpuPreAgg) on t_test a (cost=82721.66..82721.71 rows=10 width=12) (actual time=858.154..858.158 rows=3 loops=3) GPU Projection: pgstrom.nrows(), ten GPU Join Quals [1]: (id = id) [plan: 10417460 -> 416667, exec: 25000000 -> 1000000] GPU Outer Hash [1]: id GPU Inner Hash [1]: id GpuJoin buffer usage: 45.78MB GPU Group Key: ten Scan-Engine: GPU-Direct with GPU0; direct=135136, ntuples=25000000 -> Parallel Seq Scan on t_join b (cost=0.00..9606.67 rows=416667 width=4) (actual time=0.010..24.243 rows=333333 loops=3) Planning Time: 0.181 ms Execution Time: 900.389 ms (14 rows)
同じクエリを連続で実行すると、キャッシュに載っている場合は1っ回目よりも速くクエリーが帰るという実行計画を示すことがあります。
なお、同じデータをわざとPG-Stromをオフにして実行すると、このようになります。 単純計算するとこの環境のこのクエリーの場合は、PG-Stromによる効果は3倍くらいであるということがわかります。 より新しいGPUかつそのGPUがGPUDirect Storage対応*2であればもっと高速になる可能性があります。
testdb=# SET pg_strom.enabled = off; SET testdb=# EXPLAIN ANALYZE SELECT count(*) FROM t_test AS a, t_join AS b WHERE a.id = b.id GROUP BY a.ten; QUERY PLAN ------------------------------------------------------------------------------------------------------------------------ ------------------------------------- Finalize GroupAggregate (cost=298042.86..298045.39 rows=10 width=12) (actual time=2778.143..2790.725 rows=10 loops=1) Group Key: a.ten -> Gather Merge (cost=298042.86..298045.19 rows=20 width=12) (actual time=2778.136..2790.714 rows=30 loops=1) Workers Planned: 2 Workers Launched: 2 -> Sort (cost=297042.83..297042.86 rows=10 width=12) (actual time=2775.293..2775.297 rows=10 loops=3) Sort Key: a.ten Sort Method: quicksort Memory: 25kB Worker 0: Sort Method: quicksort Memory: 25kB Worker 1: Sort Method: quicksort Memory: 25kB -> Partial HashAggregate (cost=297042.57..297042.67 rows=10 width=12) (actual time=2775.268..2775.272 r ows=10 loops=3) Group Key: a.ten Batches: 1 Memory Usage: 24kB Worker 0: Batches: 1 Memory Usage: 24kB Worker 1: Batches: 1 Memory Usage: 24kB -> Parallel Hash Join (cost=14815.00..294959.23 rows=416667 width=4) (actual time=99.357..2726.17 1 rows=333333 loops=3) Hash Cond: (a.id = b.id) -> Parallel Seq Scan on t_test a (cost=0.00..239342.63 rows=10417463 width=8) (actual time= 0.045..633.978 rows=8333333 loops=3) -> Parallel Hash (cost=9606.67..9606.67 rows=416667 width=4) (actual time=97.705..97.706 ro ws=333333 loops=3) Buckets: 1048576 Batches: 1 Memory Usage: 47328kB -> Parallel Seq Scan on t_join b (cost=0.00..9606.67 rows=416667 width=4) (actual tim e=0.012..25.059 rows=333333 loops=3) Planning Time: 0.101 ms Execution Time: 2790.788 ms (23 rows)
確認が終わったら、意味がないので元に戻します。
testdb=# SET pg_strom.enabled = on;
本題のArrowデータを使った場合
さてこれでも十分効果はありますが、データをArrow形式にするとパフォーマンスが向上します。そこで利用できるのがPG-StromのArrow Toolです。いろいろなものがありますが、今回はpg2arrow を使います。
pg2arrow CLIツールは名前が示す通りのものですが、PostgreSQLのテーブルのデータをApache Arrow形式のファイルに出力するものです。rpmパッケージでPG-Stromをインストールした場合は、すでにインストールされているものを使えます。
pg2arrow -u postgres -d testdb -c "SELECT * FROM t_test" -o ./t_test.arrow pg2arrow -u postgres -d testdb -c "SELECT * FROM t_join" -o ./t_join.arrow
このファイルを任意のディレクトリーにおいて次のように登録すると、テーブルデータの一つとしてPG-StromがセットアップされたPostgreSQLで利用できます。
testdb=# IMPORT FOREIGN SCHEMA arrow_t_test FROM SERVER arrow_fdw INTO public OPTIONS (file '/opt/nvme/test/t_test.arrow'); testdb=# IMPORT FOREIGN SCHEMA arrow_t_join FROM SERVER arrow_fdw INTO public OPTIONS (file '/opt/nvme/test/t_join.arrow');
そして、登録したテーブルを使って同じようなクエリーを実行します。 するとなぜか先ほどとは異なり、GPUスケジュールされなくなるのです。
testdb=# EXPLAIN ANALYZE SELECT count(*) FROM arrow_t_test AS a, arrow_t_join AS b WHERE a.id = b.id GROUP BY a.ten; QUERY PLAN ------------------------------------------------------------------------------------------------------------------------ ------------------------------------------------------ Finalize GroupAggregate (cost=1043172698.22..1043172748.89 rows=200 width=12) (actual time=5796.043..5796.127 rows=10 loops=1) Group Key: a.ten -> Gather Merge (cost=1043172698.22..1043172744.89 rows=400 width=12) (actual time=5796.037..5796.119 rows=20 loops =1) Workers Planned: 2 Workers Launched: 2 -> Sort (cost=1043171698.19..1043171698.69 rows=200 width=12) (actual time=2247.767..2247.769 rows=7 loops=3) Sort Key: a.ten Sort Method: quicksort Memory: 25kB Worker 0: Sort Method: quicksort Memory: 25kB Worker 1: Sort Method: quicksort Memory: 25kB -> Partial HashAggregate (cost=1043171688.55..1043171690.55 rows=200 width=12) (actual time=2247.751..2 247.754 rows=7 loops=3) Group Key: a.ten Batches: 1 Memory Usage: 40kB Worker 0: Batches: 1 Memory Usage: 40kB Worker 1: Batches: 1 Memory Usage: 40kB -> Merge Join (cost=1452913.55..782755021.88 rows=52083333333 width=4) (actual time=1486.445..221 6.471 rows=333333 loops=3) Merge Cond: (a.id = b.id) -> Sort (cost=1342767.71..1368809.37 rows=10416667 width=8) (actual time=1320.699..1589.458 rows=8333332 loops=3) Sort Key: a.id Sort Method: quicksort Memory: 1048576kB Worker 0: Sort Method: quicksort Memory: 25kB Worker 1: Sort Method: quicksort Memory: 159954kB -> Parallel Foreign Scan on arrow_t_test a (cost=0.00..128580.67 rows=10416667 width=8) (actual time=23.910..673.467 r ows=8333333 loops=3) referenced: id, ten file0: /opt/nvme/test/t_test.arrow (read: 190.73MB, size: 286.10MB) -> Sort (cost=110145.84..112645.84 rows=1000000 width=4) (actual time=218.904..251.828 rows=947396 loops=2) Sort Key: b.id Sort Method: quicksort Memory: 24577kB Worker 1: Sort Method: quicksort Memory: 24577kB -> Foreign Scan on arrow_t_join b (cost=0.00..10488.00 rows=1000000 width=4) (actual time=2.548..62.313 rows=1000000 l oops=2) referenced: id file0: /opt/nvme/test/t_join.arrow (read: 3906.25KB, size: 11.45MB) Planning Time: 0.257 ms Execution Time: 5806.381 ms (34 rows)
データが小さい場合はGPUスケジュールする場合のオーバーヘッドを考慮した結果、CPUで処理するスケジューリングが選択されることがあるそうです。ただ今回の場合、Arrowデータにする前はGPUスケジュールされていましたので、それには該当しないと思います。 とはいえ、Arrowデータに変換を失敗している可能性もあるので、row数を確認してみます。
testdb=# SELECT COUNT(*) FROM t_test; count ---------- 25000000 (1 row) testdb=# SELECT COUNT(*) FROM t_join; count --------- 1000000 (1 row) testdb=# SELECT COUNT(*) FROM arrow_t_test; count ---------- 25000000 (1 row) testdb=# SELECT COUNT(*) FROM arrow_t_join; count --------- 1000000 (1 row)
結果、Arrowデータと元のテーブルのrow数は一緒であることが確認できました。データが小さい場合というケースは該当しないことがわかりました。
ANALYZEで改善するかも?
そこで開発者に相談したところ、利用しているArrowテーブルでanalyzeを実行してみて欲しいといわれました。 するとどうでしょう。ちゃんとGPUスケジューリングされて処理されたではないですか。
testdb=# ANALYZE arrow_t_join; ANALYZE testdb=# ANALYZE arrow_t_test; ANALYZE testdb=# EXPLAIN ANALYZE SELECT count(*) FROM arrow_t_test AS a, arrow_t_join AS b WHERE a.id = b.id GROUP BY a.ten; QUERY PLAN -------------------------------------------------------------------------------------------------------------------------------------- Custom Scan (GpuPreAgg) on arrow_t_test a (cost=74219.89..74220.02 rows=10 width=12) (actual time=484.577..484.581 rows=10 loops=1) GPU Projection: pgstrom.nrows(), ten GPU Join Quals [1]: (id = id) [plan: 25000000 -> 1833250, exec: 25000000 -> 1000000] GPU Outer Hash [1]: id GPU Inner Hash [1]: id GpuJoin buffer usage: 45.78MB GPU Group Key: ten referenced: id, ten file0: /opt/nvme/test/t_test.arrow (read: 190.73MB, size: 286.10MB) Scan-Engine: GPU-Direct with GPU0; direct=24414, ntuples=25000000 -> Foreign Scan on arrow_t_join b (cost=0.00..10488.00 rows=1000000 width=4) (actual time=1.082..61.836 rows=1000000 loops=1) referenced: id file0: /opt/nvme/test/t_join.arrow (read: 3906.25KB, size: 11.45MB) Planning Time: 0.182 ms Execution Time: 485.808 ms (15 rows)
ANALYZEとは
PostgreSQLの公式ドキュメント(の日本語訳)のANALYZEに関する説明を見ました。 https://www.postgresql.jp/document/17/html/sql-analyze.html
その上で、これについてChatGPT 5.2に聞いてみました。
結果的にはIMPORT FOREIGN SCHEMAでデータを登録した後は、ANALYZEしたほうがいいということみたいです。
一個だけ賢くなった(気になりました)。
Q: PostgreSQLでパフォーマンスが思ったようにでなかったのですが、analyze arrow_t_join;みたいな処理を行うと、その後は想定したクエリーパフォーマンスがでるようになりました。この理由は何が考えられますか?
A: 考えられる理由は「統計情報(planner statistics)が更新され、実行計画が変わった/安定した」ことです。ANALYZE arrow_t_join; はテーブル(と関連インデックス)をサンプリングして統計情報を作り直し、以後のクエリ最適化(プラン選択)に使われます。その結果、同じSQLでも速い計画が選ばれるようになり得ます。
1) 統計が古い・無いせいで行数見積もりを外していた
PostgreSQL は 行数推定(cardinality estimation) に基づいて、Nested Loop / Hash Join / Merge Join、Index Scan / Seq Scan、並列度などを選びます。 統計が古い(大量INSERT/DELETE/UPDATE直後、初回ロード直後、autovacuum/analyzeが追いついてない等)だと、 • 返る行数を過小評価 → Nested Loop + インデックスで地雷(実際は大量に回る) • 返る行数を過大評価 → Seq Scan や Hash を選ぶがメモリ不足でスピル • 選択度(WHEREの効き)がズレる → インデックスが有効なのに使わない(または逆)
のような “誤った計画” になり、遅くなります。ANALYZE 後に期待性能が出たなら、まずこれが本命です。
2) 相関(correlation)や分布(MCV)が更新され、スキャン方法が変わった
ANALYZE は列ごとの分布(Most Common Values、ヒストグラム、NULL率など)や、テーブル物理順序との相関も更新します。これにより • 範囲条件で Index Scan が効く/効かないの判断 • JOIN の結合順序 • “その値がどれくらいレアか” の判断
が改善し、プランが変わります。
3) 複数列の依存関係や結合列の情報不足が解消された
WHERE 条件が複数列にまたがる、JOIN 条件が複数列、等だと「列が独立」という仮定で推定が外れやすいです。統計が更新されるだけでも改善しますが、場合によっては 拡張統計(CREATE STATISTICS) を入れるとさらに安定します(dependencies / ndistinct / mcv)。
4) 自動ANALYZEが走っておらず、手動ANALYZEで初めて最適化できた
autovacuum の analyze は「変更行がしきい値を超えたら」動きます。大量ロード直後でも、設定や負荷状況次第で遅延することがあります。手動 ANALYZE がその穴を埋めたケース。
CM
PG-Stromを気になった方はぜひ弊社までお問い合わせください。