PostgreSQL v11新機能先取り:Hash-PartitioningとParallel-Append

今回のエントリーは PostgreSQL Advent Calendar 2017 - Qiita に参加しています。

PG-Stromの視点からも、PostgreSQL v11には首を長くして待っていた機能が2つ入っている。

その1:Hash-Partitioning
github.com

その2:Parallel-Append
github.com

Hash-Partitioningというのは、PostgreSQL v10で追加されたテーブルパーティショニング機能の拡張で、日付時刻などの幅(Range)でパーティション化を行うのではなく、レコードの値をハッシュ関数に通して得られた値を元に、振り分ける先の子テーブルを選択して書き込みを行うというもの。

特徴としては、データの母集団が特異なものでない限り*1、各子テーブルへの書込みは均等に平準化されることになる。これは後で説明する通り、子テーブルのスキャンを並列にできれば、ストレージ帯域を有効に活用する事ができる非常に有益な特性である。


例えば、キー値 'hoge' のハッシュ値が 123 であった場合、これを4で割ると余り3なのでテーブル 't3' に、キー値 'monu' のハッシュ値が 234 であった場合、同様にこれを4で割ると余り3なのでテーブル 't0' に格納されるという流れである。

それでは、試してみる事にする。

まず、テーブルスペースを作成する。
評価用サーバにNVMe-SSDを3枚装着していたので、とりあえず、これを使う事にする。

postgres=# create tablespace nvme1 location '/opt/nvme1';
CREATE TABLESPACE
postgres=# create tablespace nvme2 location '/opt/nvme2';
CREATE TABLESPACE
postgres=# create tablespace nvme3 location '/opt/nvme3';
CREATE TABLESPACE

続いて、パーティションの親テーブルを作成する。partition by hashというのが、Hash-Partitioningを指定するためのオプション。

postgres=# create table t (id int, x real, y real, z real, sum text) partition by hash (id);

パーティションの子テーブルを6つ作成。それぞれ、テーブルスペースあたり2個のテーブルをホストするように設定している。
modulesというのがハッシュ値の分母となる数。remainerというのが計算結果の値である。

postgres=# create table t_0 partition of t for values with (modulus 6, remainder 0) tablespace nvme1;
CREATE TABLE
postgres=# create table t_1 partition of t for values with (modulus 6, remainder 1) tablespace nvme1;
CREATE TABLE
postgres=# create table t_2 partition of t for values with (modulus 6, remainder 2) tablespace nvme2;
CREATE TABLE
postgres=# create table t_3 partition of t for values with (modulus 6, remainder 3) tablespace nvme2;
CREATE TABLE
postgres=# create table t_4 partition of t for values with (modulus 6, remainder 4) tablespace nvme3;
CREATE TABLE
postgres=# create table t_5 partition of t for values with (modulus 6, remainder 5) tablespace nvme3;
CREATE TABLE

さくっとデータを流し込んでみる。

postgres=# insert into t (select x,100*random(), 100*random(), 100*random(), md5(x::text)
                            from generate_series(1,600000000) x);
INSERT 0 600000000

postgres=# \d+
                   List of relations
 Schema | Name | Type  | Owner  |  Size   | Description
--------+------+-------+--------+---------+-------------
 public | t_0  | table | kaigai | 8057 MB |
 public | t_1  | table | kaigai | 8057 MB |
 public | t_2  | table | kaigai | 8056 MB |
 public | t_3  | table | kaigai | 8055 MB |
 public | t_4  | table | kaigai | 8056 MB |
 public | t_5  | table | kaigai | 8056 MB |
(6 rows)

各テーブルに均等に書き込まれているのが分かる。

Parallel Append による並列処理

では、スキャン時にこれがどのように処理されるのかを見てみる事にする。

まず、CPUパラレルがない場合。

postgres=# set max_parallel_workers_per_gather = 0;
SET
postgres=# explain select count(*),avg(x) from t where y < z;
                                 QUERY PLAN
----------------------------------------------------------------------------
 Aggregate  (cost=14685571.72..14685571.73 rows=1 width=16)
   ->  Append  (cost=0.00..13685571.40 rows=200000064 width=4)
         ->  Seq Scan on t_0  (cost=0.00..2281105.20 rows=33335925 width=4)
               Filter: (y < z)
         ->  Seq Scan on t_1  (cost=0.00..2281087.50 rows=33335667 width=4)
               Filter: (y < z)
         ->  Seq Scan on t_2  (cost=0.00..2280943.70 rows=33333565 width=4)
               Filter: (y < z)
         ->  Seq Scan on t_3  (cost=0.00..2280662.70 rows=33329459 width=4)
               Filter: (y < z)
         ->  Seq Scan on t_4  (cost=0.00..2280901.60 rows=33332949 width=4)
               Filter: (y < z)
         ->  Seq Scan on t_5  (cost=0.00..2280870.70 rows=33332499 width=4)
               Filter: (y < z)
(14 rows)

この実行計画は Gather ノードを含んでいない。つまり、Appendは t_0、t_1、…、t_5 までの全件スキャンを順番に処理するという事を意味している。つまり、t_0とt_5が別のストレージに格納されていても、片方を読み出している時には他方は全く使われていない訳で、ストレージ分散の恩恵は全くない。

次に、CPUパラレルを有効にした場合。

postgres=# set max_parallel_workers_per_gather = 100;
SET
postgres=# explain select count(*),avg(x) from t where y < z;
                                           QUERY PLAN
------------------------------------------------------------------------------------------------
 Finalize Aggregate  (cost=7400855.85..7400855.86 rows=1 width=16)
   ->  Gather  (cost=7400855.10..7400855.81 rows=7 width=40)
         Workers Planned: 7
         ->  Partial Aggregate  (cost=7399855.10..7399855.11 rows=1 width=40)
               ->  Parallel Append  (cost=0.00..7256997.91 rows=28571438 width=4)
                     ->  Parallel Seq Scan on t_0  (cost=0.00..1209593.31 rows=4762275 width=4)
                           Filter: (y < z)
                     ->  Parallel Seq Scan on t_1  (cost=0.00..1209583.93 rows=4762238 width=4)
                           Filter: (y < z)
                     ->  Parallel Seq Scan on t_2  (cost=0.00..1209507.67 rows=4761938 width=4)
                           Filter: (y < z)
                     ->  Parallel Seq Scan on t_3  (cost=0.00..1209358.67 rows=4761351 width=4)
                           Filter: (y < z)
                     ->  Parallel Seq Scan on t_4  (cost=0.00..1209485.37 rows=4761850 width=4)
                           Filter: (y < z)
                     ->  Parallel Seq Scan on t_5  (cost=0.00..1209468.96 rows=4761786 width=4)
                           Filter: (y < z)
(17 rows)

Gatherノードの下に、PostgreSQL v11で新たにサポートされた Parallel Append が出現している。
Gatherノードはワーカープロセスを起動する役割を担っており、ここで起動されたワーカープロセスが、Parallel Appendノード以下のどこかのテーブルスキャンを担うようになる。
そうすると、t_0とt_5は互いに別々のストレージに格納されているので、読出し処理を互いに並列に実行する事ができるようになる。

実際に走らせてみると、以下の通り。158.4sec → 27.6sec へと高速化しているのが分かる。

postgres=# \timing on
Timing is on.

postgres=# set max_parallel_workers_per_gather = 0;
SET
postgres=# select count(*),avg(x) from t where y < z;
   count   |       avg
-----------+------------------
 300006886 | 50.0030065651906
(1 row)

Time: 158412.708 ms

postgres=# select count(*),avg(x) from t where y < z;
   count   |       avg
-----------+------------------
 300006886 | 50.0030065651742
(1 row)

Time: 27597.358 ms

PostgreSQLのshared_buffersは最小限に設定。OSキャッシュは実行前にクリアしています。

iostatで見てみると、こんな感じになっている。
■ CPU並列なしの場合

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           3.61    0.00    0.56    0.00    0.00   95.83

Device:            tps    MB_read/s    MB_wrtn/s    MB_read    MB_wrtn
nvme0n1           0.00         0.00         0.00          0          0
nvme1n1        5011.00       313.12         0.00        626          0
nvme2n1           0.00         0.00         0.00          0          0
sda               0.00         0.00         0.00          0          0

■ CPU並列ありの場合

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
          23.66    0.00    3.16    4.71    0.00   68.47

Device:            tps    MB_read/s    MB_wrtn/s    MB_read    MB_wrtn
nvme0n1       10163.00       634.81         0.00       1269          0
nvme1n1       10354.00       647.02         0.07       1294          0
nvme2n1        8610.00       537.76         0.01       1075          0
sda               0.00         0.00         0.00          0          0

各バックグラウンドワーカーが均等にI/Oを発行している様子がわかる。

マルチGPU対応とSSD-to-GPUダイレクトSQL実行

この機能は、PG-Stromにとっても非常に重要な技術基盤になっている。

というのも、中核機能の一つであるSSD-to-GPUダイレクトSQL実行は、NVIDIA GPUDirect RDMAという基盤を用いて作られており、これには次のような制約があるからだ。

We can distinguish between three situations, depending on what is on the path between the GPU and the third-party device:

  • PCIe switches only
  • single CPU/IOH
  • CPU/IOH <-> QPI/HT <-> CPU/IOH

The first situation, where there are only PCIe switches on the path, is optimal and yields the best performance. The second one, where a single CPU/IOH is involved, works, but yields worse performance ( especially peer-to-peer read bandwidth has been shown to be severely limited on some processor architectures ). Finally, the third situation, where the path traverses a QPI/HT link, may be extremely performance-limited or even not work reliably.
GPUDirect RDMA :: CUDA Toolkit Documentation

つまり、マルチソケットシステムで異なるCPU配下に接続されているGPUSSDとの間では、GPUDirect RDMAのスループットが極端に低下するか動作しない、という事である*2

PostgreSQLのワーカープロセス上で動作するPG-Stromにとって、複数のGPUが搭載されていた場合でも、どのGPUを使用するか決定するというのはさほど難しい話ではない。基本的にはラウンドロビンで、例えば7プロセスでCPU並列処理を行う場合、4プロセスはGPU0を、3プロセスはGPU1を使用するといった形になる。

だが、SSDが絡むと少々厄介である。

データの格納されているテーブルを移動させるわけにはいかないので、SSD-to-GPUダイレクトSQL実行を使う場合には、スキャンしようとしているSSDと同じCPUソケットに接続されているGPUを使用する必要がある。
したがって、複数のGPUが搭載されていたとしても、Append処理がシーケンシャルである限り、スキャン対象のテーブルはシーケンシャルにしか選択されず、SSD-to-GPUダイレクトを実行中のGPUだけがアクティブで他のGPUは遊んでしまわざるを得ないという課題があった。

が、この制限は、Append処理がCPU並列に対応する事で解消される。

しかも、PostgreSQL v11では同時にHash-Partitioningがサポートされることに伴い、ストレージを跨ったデータの分散配置と、マルチGPU環境下におけるSSD-to-GPUダイレクトSQL実行を共存させる事ができるようになる。ブラボー!


PCIeバスの限界を越えたクエリ処理能力の実現に向けて

そもそも SSD-to-GPUダイレクトSQL実行というのはどういった機能であったかというと、SSD上のデータブロックをGPUに直接転送し、CPU/RAMへロードする前に不要なデータをそぎ落とす。これにより、CPUが処理すべきレコード数を減らし、あたかもI/Oが高速化されたかのように振る舞う、というものであった。

これは、典型的な集計系のSQLワークロードがWHERE句やGROUP BYによってデータ件数を大幅に減らす事ができるという特性に基づいている。

さて、世の中には PCIe バスを引き回して外部の拡張I/OボックスにGPUなどのカードを装着する事のできるデバイスが存在する。

ちょっとこれら各製品の対応状況を調べねばならないが、拡張I/Oボックス内にPCIeスイッチが搭載されており、拡張I/Oボックス内でSSD-to-GPUのデータ転送が完結できる製品であれば*3、データ転送の大部分をボックス内で消し込む事が可能であるはずである。

例えば、10億件のレコードをテーブルから読み出し、1000件の集計結果を出力するようなクエリであれば、拡張I/Oボックス⇒ホストシステム間の帯域はほとんど問題にはならない*4

そうすると、拡張I/Oボックス一個あたりGPUx1とSSDx2を詰め込んで、一台増設すれば8.0~10.0GB/s程度の性能向上と数TBの容量拡張を行えるソリューションという形にする事もそうそう突飛な話ではなくなる。

ラックサーバ単体だと、GPUSSDを搭載するスロットの物理的形状やPCIeレーン数の制約から1CPUあたり高々10GB/sが上限にならざるを得ないが、拡張I/Oボックスを使えば、これを軽々突破するクエリ処理能力というのが実現可能と考えられる。
しかも、アプリケーション視点から見ると、これは単純にたくさんのGPUSSDを搭載したPostgreSQLシステムという事になるので、分散トランザクションを意識する必要は全くない。

来年に向けて、面白くなってきたのではないだろうか。

*1:例えばハッシュ化すべき列が全て同じ値、とか

*2:で、これを回避するために、HPC向けなどのサーバではPCIeスイッチを介して全てのGPUが特定のルート配下で動作するように設計された製品がある。Supermicro SYS-4028GR-TRT2DELL PowerEdge C4130など。

*3:普通に考えたら、ボックス内のPCIeレーン数よりアップリンクのPCIeレーン数が少ないので、スイッチが入ってるはず。

*4:まぁ、書き込み遅いと困るのでPCIe x4くらいはほしいけど。