仮想化通信

日本仮想化技術株式会社の公式エンジニアブログ

PG-StromとArrow形式のデータでパフォーマンスがでないケースがある

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を気になった方はぜひ弊社までお問い合わせください。

問い合わせ

virtualtech.jp

その他にもいろいろ取り扱っています

virtualtech.jp

*1:非ライセンスの場合は一つのストレージのみサポート

*2:旧称でいうところのTeslaやQuadroシリーズであれば対応しています。ようはゲーミング用のGeForceでなければ動作します。ただし現在のGDSの要件上、Turing世代以降のGPUが必要です