GPUメモリストア(Gstore_Fdw)

この記事は「PostgreSQL Advent Calendar 2020」の 16日目です。

GPUPostGISの他に、今年のPG-Stromの機能強化のうち比較的大きめのものについてもご紹介したいと思います。

GPUメモリストア(Gstore_Fdw)とは

GPUバイスメモリ上に予め確保した領域にデータを保存し、これをPostgreSQLのFDW(Foreign Data Wrapper)を通じて読み書きする機能。GpuScan/GpuJoin/GpuPreAggといったPG-Stromの提供する各種ロジックにおいてデータソースとして活用する事ができ、その場合、ストレージやホストRAM上のバッファからデータを読み出す必要がないため、その分の処理を節約する事ができる。

この手の機能を持ったGPU-DBというのは他にもあるが、Gstore_Fdwのポイントは更新系ワークロードもきちんと考慮している点。通常、GPUバイスメモリを更新するには、PCI-Eバスを経由してデータを転送する必要があるが、これのレイテンシが馬鹿にならない。
大雑把に言って、ホストRAMの更新が数十nsのオーダーである一方、PCI-Eバスを介したGPUバイスメモリの読み書きには数十usを要する。つまり千倍差である。*1

では Gstore_Fdw ではどうしているのか?
およそ数百~数万行分の更新ログを溜めておいて、あとで一気にGPUへ転送し、GPU側では数千コアを同時に動かして更新ログをGPUバイスメモリ上のデータストアに適用する。そうする事で、比較的大きなPCI-Eのレイテンシも一行あたりに直せば大した値ではなくなる。

更新ログをGPUに転送するのは以下の3つのタイミング。

  • 未適用の更新ログがあり、それが一定の閾値を越えた場合。
  • 未適用の更新ログがあり、最終更新から一定の時間を経過した場合。
  • GPUバイスメモリを参照する分析クエリを実行する場合。

3つ目がポイントで、ここでは暗黙の裡に分析クエリの実行頻度は更新クエリよりも遥かに少ないという仮定を置いているが、GPUバイスメモリ側の更新がどれだけ遅延したとしても、分析クエリの実行より前に最新状態にリフレッシュできていれば問題ない、という事である。

この機能の使い道として想定しているのは、携帯電話や自動車、ドローンなど、GPSによる位置情報を時々刻々更新するようなパターンのワークロードで、GPUPostGISと組み合わせて利用するパターンを念頭に置いている。
この手のログデータは意外とデータサイズは小さい*2が、更新の頻度が極めて高いという特徴を持つ。

Gstore_Fdw で外部テーブルを定義してみる。

では早速、Gstore_Fdwを用いた外部テーブルを定義してみることにする。
開発用サーバには2台のGPUが搭載されているので、PostgreSQLパーティション機能を用いて両方のGPUにデータを振り分ける。

まず、パーティションのRootとなるテーブルを定義。

=# create table fpoints (
     dev_id int,
     ts timestamp,
     x float,
     y float)
   partition by hash ( dev_id );
CREATE TABLE

続いて、パーティションLeafとしてGstore_Fdw外部テーブルを定義。

=# create foreign table fpoints_p0
     partition of fpoints for values with (modulus 2, remainder 0)
     server gstore_fdw
     options (base_file '/opt/pgdata12/fpoints_p0.base',
              redo_log_file '/opt/pmem/fpoints_p0.redo',
              gpu_device_id '0',
              max_num_rows '8000000',
              primary_key 'dev_id');
CREATE FOREIGN TABLE
=# create foreign table fpoints_p1
     partition of fpoints for values with (modulus 2, remainder 1)
     server gstore_fdw
     options (base_file '/opt/pgdata12/fpoints_p1.base',
              redo_log_file '/opt/pmem/fpoints_p1.redo',
              gpu_device_id '1',
              max_num_rows '8000000',
              primary_key 'dev_id');
CREATE FOREIGN TABLE
  • base_fileというのはデータストアを記録するためのファイルで、再起動やクラッシュ後のリカバリのために使用される。
  • redo_log_fileというのは更新ログを記録するためのファイルで、Persistent Memory領域を使用するのが推奨。今回は、Intel製DCPMM[128GB]をマウントした/opt/pmem以下のファイルを指定している。
  • gpu_device_idは使用するGPUを指定し、max_num_rowsはバッファを確保する最大行数を指定する。
  • primary_keyには主キーとして振る舞うカラムを指定する。

これらの外部テーブルを定義すると、ログには以下のように表示され、GPUバイスメモリをそれぞれ 300MB 程度確保した事がわかる。

2020-12-16 08:51:48.668 UTC [11130] LOG:  gstore_fdw: initial load [fpoints_p0] - main 324000640 bytes, extra 0 bytes
2020-12-16 08:51:58.265 UTC [11129] LOG:  gstore_fdw: initial load [fpoints_p1] - main 324000640 bytes, extra 0 bytes

初期データの投入と更新クエリの実行

続いて、GPUメモリストアに初期データの投入を行う。これはINSERTでもCOPY FROMでもよい。

=# insert into fpoints (select x, now(), 100*random(), 100*random() from generate_series(0,12000000) x);
INSERT 0 12000001

Hashパーティショニングを使用しているため、概ね均等にデータが分散されていることが分かる。

postgres=# select count(*) from fpoints;
  count
----------
 12000001
(1 row)

postgres=# select count(*) from fpoints_p0;
  count
---------
 5998934
(1 row)

postgres=# select count(*) from fpoints_p1;
  count
---------
 6001067
(1 row)

このデータに対して、位置情報とタイムスタンプの更新を想定し、以下のようなクエリをpgbenchで実行してみることにする。
tsはタイムスタンプの更新、x,yはそれぞれ倍精度浮動小数点による座標のイメージである。

UPDATE fpoints SET ts = now(),
                   x = random() * 100.0,
                   y = random() * 100.0
             WHERE dev_id = (select (random() * 250000)::int * 48 + :client_id)

実行結果は以下のような感じ。

$ pgbench -n -f mytest.sql -c 48 -j 48 -T 15 postgres
transaction type: mytest.sql
scaling factor: 1
query mode: simple
number of clients: 48
number of threads: 48
duration: 15 s
number of transactions actually processed: 1471332
latency average = 0.490 ms
tps = 97912.340285 (including connections establishing)
tps = 97950.056405 (excluding connections establishing)

これに伴い、ログにも溜まった更新ログをGPUへ転送し、これをGPUバイスメモリ上のデータストアに適用した事が出力されている。

2020-12-16 09:24:27.484 UTC [11129] LOG:  gstore_fdw: Log applied (nitems=1905688, length=124817704, pos 521268808 => 612741824)
2020-12-16 09:24:28.294 UTC [11130] LOG:  gstore_fdw: Log applied (nitems=2037144, length=131644512, pos 521086144 => 618869072)
2020-12-16 09:24:42.567 UTC [11129] LOG:  gstore_fdw: Log applied (nitems=823198, length=76151024, pos 612741824 => 652255360)
2020-12-16 09:24:43.390 UTC [11130] LOG:  gstore_fdw: Log applied (nitems=824461, length=76733600, pos 618869072 => 658443232)
2020-12-16 09:24:57.686 UTC [11129] LOG:  gstore_fdw: Log applied (nitems=1574755, length=118524736, pos 652255360 => 727843584)
2020-12-16 09:24:58.497 UTC [11130] LOG:  gstore_fdw: Log applied (nitems=1436306, length=111847320, pos 658443232 => 727385888)

分析クエリの実行はこのような形で、fpointsテーブル(実際にはその配下のfpoints_p0とfpoints_p1外部テーブル)から、指定範囲内に属する座標を抽出するというような形での利用を想定している。
これは別に単純検索である必要はなく、例えば、前回の記事で紹介したようなGiSTインデックスを用いた多対多の範囲検索であってもよい。

=# SELECT count(*) FROM fpoints
 WHERE st_contains('polygon ((10 10,90 10,90 12,12 12,12 88,90 88,90 90,10 90,10 10))', st_makepoint(x,y));
 count
--------
 565047
(1 row)

Time: 254.274 ms

Gstore_Fdwの今後

現在、PG-Strom v3.0のリリースに向けてマルチGPUの対応をはじめとした諸々の改善や、テストケースの作成を行っています。
乞うご期待!

ちなみに、Advent Calendarとしてはずいぶんギリギリの公開となってしまいましたが、こういった悲しい事故がありました。

*1:HPC-oriented Latency Numbers Every Programmer Should Know · GitHub

*2:例えば、1デバイスあたり1kBのデータを保持していても、100万台分で1GBにしかならない。