PG-Strom v6.0をリリースしました。
といった、いくつかの重要機能を含むリリースで、特にGPU-Sortによって新しいワークロードへの対応が広がったという事でバージョン6.x系列としました。
その主要機能について、何回かに分けて解説していきたいと思います。
マルチGPU環境でのPinned Inner Bufferの動きは?
前回、Large Tables JOIN を高速化する Pinned Inner Buffer について解説しました。
これは、GPU-Scanの結果をCPU側へ戻さず、そのままGPUメモリに留置する事でGPU-Joinで使用するInner BufferをCPUで構築する処理を省略するという技術ですが、では、マルチGPUの環境ではどうなるのでしょうか?
なかなか個人では難しいですが、AWSやAzure、OCIなどの大手クラウドでは最新GPUを8台搭載したモンスターマシンがお手頃価格*1で利用できますし、マルチGPUであればGPU-JoinのInner-Bufferもより大きなサイズを利用できるのではないかというのは当然の期待でしょう。
先ず、大前提としてPG-StromはGPU-JoinのInner-BufferをCUDA Managed Memoryを用いて割り当てています。
この Managed Memory というのは10年以上前のKepler世代(CC3.0)から存在する機能で、CPUとGPUで同じ論理アドレス空間を使用し、その論理アドレス空間を読み書きした際に、もしも対応する物理ページが存在しなくとも、Page Faultを使ってGPUからCPUへ、CPUからGPUへとページ単位でコピーを行い、あたかも1個のメモリ領域をCPUとGPUで共有するための機能です。
Pascal世代(CC6.0)以降では、Manager Memoryをアロケートした時点では論理アドレス空間のみを割り当て、そのページを読み書きするたびにPage Faultを発生させて物理ページを割り当てるため、行列演算などと異なり、予め処理結果を保存するためのバッファサイズを読みにくいSQLのワークロードには大変ありがたい。
しかも、GPUのデバイスメモリが溢れた場合には、あたかもOSのスワップのように、GPUメモリの内容をCPUメモリに退避してくれるため、GPUメモリサイズ以上のバッファを消費しても処理を継続できます(GPU Memory Oversubscription)。
kaigai.hatenablog.com
実際、PG-Stromでもこの機能を便利に多用しており、これが『PG-StromはPascal世代以降でしか動作しない』と2017年の時点で旧世代のGPUを全てぶった切った最大の理由であります。
では、性能の方はどうでしょうか?
GpuJoinのPinned Inner Bufferを有効にして、前回のテストで用いたクエリの条件句o_orderdate > '1997-05-01'を調整してやれば、orders表のうちGpuJoin Inner Bufferに乗るデータサイズを調整する事ができます。
select l_shipmode, o_orderpriority, sum(l_extendedprice) from lineitem, orders where l_orderkey = o_orderkey and o_orderdate > '1997-05-01' group by l_shipmode, o_orderpriority;
そうして、Inner Bufferのサイズを徐々に大きくしていったものが以下のグラフです。横軸がInner Bufferのサイズ、縦軸がクエリの応答時間です。
すごいですね(棒)。このGPUのメモリサイズは40GBですが、GPU-Joinを実行するための最低限のワーキングとして4.2GBを要した上で、Pinned Inner Bufferのサイズが39GBを越えたあたりで急速にクエリの実行時間が伸びています。

合計すると44GBほどのメモリ消費で「壁」を越えられないように見えるかもしれませんが、実際にはInner Bufferの中でもGPU-Joinの処理中には滅多に参照されない行インデックスの領域が3.6GB(8byte x 4.7億行)ほどありますから、実態としてはGPUメモリのサイズを越えるようなメモリオーバーサブスクリプションは(特にHash-Joinのようなランダムアクセス性の強いものは)厳禁という事なのでしょう。
マルチGPUをどう活用するか
先ほどの実験より、Managed MemoryでGPUメモリ以上のバッファサイズを確保できるとは言えども、論理アドレスの世界からは見えなくしている物理的な制約からはどうしても逃れられない事が分かりました。つまり、単純に2GPUが使えるからと言って、倍のサイズのInner Bufferを確保しても性能が出るとは思えないのです。では、どうしたものでしょうか?
PG-Strom v6.0では、GPUメモリよりも大きな(実際にはpg_strom.pinned_inner_buffer_partition_sizeの設定値; GPUメモリの80~90%)Inner Bufferを複数のGPUに分割するよう試みます。
ここで重要なのは、GPU-Joinの実行中にできる限りPage Faultを避けるような分割を行わないと、GPU-DRAMに比べてもはるかに低速なPCI-Eバス上のデータ移動によってかなり破滅的な性能の低下が起こってしまうという点に留意する事です。つまり、JOIN処理で結合先の行を探す時には、そのGPUに存在しない行をハナから探しに行ってはいけないのです。(それでPage Faultを起こせばミイラ取りが・・・)
Inner Bufferの分割には、Hash-Joinで用いるハッシュ値を用います。
一般的にHash-Joinでinner_table.X = outer_table.Yという条件の結合を処理する際、outer_table.Yのハッシュ値を計算し、予めハッシュ表にロードされたinner_tableのうち、inner_table.Xのハッシュ値と一致するものとだけ値が同一かどうか検査します。
つまり、複数のGPUに分割してハッシュ表を格納する場合であっても、一定のルールに基づいて「このGPUにこのハッシュ値のINNER側の行は存在しない」という事を断言できるようにすれば、余計なPage Faultを引き起こしてしまう事はありません。

PG-Stromは、Hash-Joinで使用するハッシュ値をパーティション数で割った剰余を使って場合分けをします。
前提として、OUTER側のテーブルから読み出した行は、ハッシュ値を計算したとしてもバラバラの値が混在しています。これはディスクから読み出したばかりのデータを、ディスク上に記録されている順序のまま読み出すのですから当然です。
しかし、Inner Hash Tableの方は予めJOIN結合キーが分かっており、それを元にGPUメモリ上で再編する事も可能です。
図のようなケースでは、Inner Bufferを3つのパーティションに分割し、各パーティションがそれぞれ3台のGPUにロードされています。
GPU0にはハッシュ値をパーティション数(= 3)で割った剰余が0の行が、GPU1には剰余が1の、GPU2には剰余が2の行がそれぞれロードされています。
そして、ストレージから読み出したデータ(通常は約64MBのデータブロック)がGPU2にロードされると、まず、GPU2ではハッシュ値の剰余が2である行だけを、GPU1では剰余が1の、GPU0では剰余が0の行だけを処理します。
OUTER側の64MBブロックは、最初にGPU2にロードされると、次はGPU-to-GPUコピーによって隣のGPUへ次々と転送されていきます。しかし、GPU同士の通信は共にPCI-E x16レーンの広帯域を持っているためかなりのスピードが期待できますし、サーバーによってはSSD-to-GPUに用いるPCI-Eの経路ではなく、GPU同士を結合するNVLinkの通信を利用できるかもしれません。(こちらは未検証)
マルチGPUでのPinned Inner Bufferの動き
先ほどのTPC-Hデータセットを用いたクエリを利用して、マルチGPUでのPinned Inner Bufferの動きを観察してみます。
tpch=# set pg_strom.cpu_fallback = off;
SET
tpch=# set pg_strom.pinned_inner_buffer_threshold = '2GB';
SET
tpch=# explain analyze select l_shipmode, o_orderpriority, sum(l_extendedprice)
from lineitem, orders
where l_orderkey = o_orderkey
and o_orderdate > '1996-01-01'
group by l_shipmode, o_orderpriority;
NOTICE: pinned inner-buffer partitions (depth=1, divisor=2)
NOTICE: partition-0 (GPUs: 00000001)
NOTICE: partition-1 (GPUs: 00000002)
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------------------
Gather (cost=25490254.67..25490258.88 rows=35 width=59) (actual time=78253.459..78253.560 rows=35 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Result (cost=25489254.67..25489255.38 rows=35 width=59) (actual time=78241.277..78241.285 rows=12 loops=3)
-> Parallel Custom Scan (GpuPreAgg) on lineitem (cost=25489254.67..25489254.85 rows=35 width=59) (actual time=78241.275..78241.280 rows=12 loops=3)
GPU Projection: pgstrom.psum(l_extendedprice), l_shipmode, o_orderpriority
GPU Join Quals [1]: (l_orderkey = o_orderkey) [plan: 2500102000 -> 979607700, exec: 548509 -> 94208]
GPU Outer Hash [1]: l_orderkey
GPU Inner Hash [1]: o_orderkey
GpuJoin buffer usage: 4096B
GPU Group Key: l_shipmode, o_orderpriority
Scan-Engine: GPU-Direct with 2 GPUs <0,1>; direct=115517748, ntuples=548509
-> Parallel Custom Scan (GpuScan) on orders (cost=100.00..5285854.67 rows=244887632 width=24) (actual time=9926.267..9926.269 rows=-1 loops=3)
GPU Projection: o_orderpriority, o_orderkey
GPU Pinned Buffer: nitems: 588525682, usage: 48.23GB, total: 61.50GB, num-partitions: 2
GPU Scan Quals: (o_orderdate > '1996-01-01'::date) [plan: 1499973000 -> 244887600, exec: 1500000000 -> 588525682]
Scan-Engine: GPU-Direct with 2 GPUs <0,1>; direct=26843246, ntuples=1500000000
Planning Time: 0.331 ms
Execution Time: 78253.739 ms
(19 rows)GPU Pinned Buffer:行を見てみると、Pinned Bufferのサイズは61.50GBです。NVIDIA A100 [40GB; PCI-E] には物理的に載らないサイズですが、num-partitions: 2と2つに分割されている事が分かります。
実行中のI/Oの帯域と、GPU2台の使用率を見てみる事にします。
すると、ordersテーブルをスキャンしたと思しき処理の後、12秒程度、I/Oが休止している時間帯がある事が分かります。
これがHash値を元にPinned Inner Bufferの内容をGPU0とGPU1に再編している処理のタイミングで、決して無視できるレベルではないのですが、5.8億行、61.5GBのハッシュ表を移し替えるための処理としてはそこそこの所要時間と言えるでしょう。

次にGPU使用率を見てみます。order表のスキャン中はGPU0、GPU1ともに40%程度のGPU使用率に留まっており、ここはGPU負荷のそれほど大きくない処理である事が分かります。次いで、GPU0が100%に、そしてGPU1が100%となる時間帯がそれぞれ5~6秒ほどあり、ここでパーティションの再編を行っているものと思われます。その後、GPU0とGPU1が共に使用率80~90%程度で、ややI/Oの帯域が低下している時間帯が続きます。

ここはGPU-Joinの実行中で、GPU0での処理を終わった後にOUTER側テーブル(lineitem)から読み出したデータをGPU0->GPU1へ転送するために消費するPCI-Eバスの帯域、またはその逆にGPU1->GPU0へ転送するために消費するPCI-Eバスの帯域によって、ストレージから読み出しを行っている分の帯域が抑えられているものと思われます。
Inner Bufferのサイズ差による影響
最後に、Inner Bufferのサイズを色々と調整した場合の振る舞いを比較してみる事にします。
前述のように、orders表の検索条件をスライドする事でINNER側のバッファサイズを調整して試す事ができます。
| 検索条件 | 件数(百万) | 旧GPU-Join Buffer-size |
旧GPU-Join 実行時間 |
新GPU-Join Buffer-size |
分割数 | 新GPU-Join 実行時間 |
PostgreSQL Hash-Size |
PostgreSQL 実行時間 |
|---|---|---|---|---|---|---|---|---|
| 1998-01-01 | 132.80 | 10.88GB | 65.28 | 14.00GB | 1 | 53.07 | 7.44 | 262.94 |
| 1997-09-01 | 208.86 | 17.12GB | 76.94 | 21.96GB | 1 | 54.13 | 11.41 | 319.65 |
| 1997-05-01 | 285.54 | 23.40GB | 89.85 | 29.95GB | 1 | 56.28 | 15.42 | 377.57 |
| 1997-01-01 | 360.36 | 29.53GB | 102.78 | 37.75GB | 2 | 71.82 | 19.33 | 434.74 |
| 1996-09-01 | 436.41 | 35.77GB | 116.22 | 45.67GB | 2 | 73.58 | 23.31 | 492.16 |
| 1996-05-01 | 513.09 | OOM | ---- | 53.65GB | 2 | 77.40 | 27.31 | 551.69 |
| 1996-01-01 | 588.53 | OOM | ---- | 61.50GB | 2 | 79.84 | 31.25 | 608.58 |
| 1995-09-01 | 664.58 | OOM | ---- | 69.44GB | 3 | 189.41 | 35.23 | 670.02 |
| 1995-05-01 | 741.26 | OOM | ---- | 77.42GB | 3 | 189.81 | 39.24 | 727.64 |
| 1995-01-01 | 816.07 | OOM | ---- | 85.21GB | 3 | 231.76 | 43.14 | 784.58 |
従来のGPU-Joinの場合、GPUメモリサイズを越えるバッファの取扱いが十分でない事もあり、検索条件をo_orderdate > '1996-09-01'より緩くした場合のデータは測定できませんでした。
一方でPinned Inner Bufferを使用した場合、適切にバッファを分割する事で、検索条件を緩くしてもGPU-Joinを実行できるようになっています。
この場合、85GBのバッファと882GBのOUTER側テーブルとのJOINとなりますので、かなりの規模のテーブル同士でJOINを実行している事になります。
また、PostgreSQLの場合はCPU側でバッファを作成しており、実行時間自体はGPU-Joinよりかなり長いのですが、バッファサイズが小さい事に気が付かれたかもしれません。これは、歴史的な経緯などから、GPU-Joinに用いるハッシュ表のデータ形式が必ずしも十分にコンパクトでない(= 無駄がある)という事でもあります。
これは今後の課題と考えており、例えば1995-01-01のケースでCPU並みにバッファを削減できれば、43.14GBとなって余裕で2GPUに分割可能であり、その場合は80秒弱で当該のクエリを実行できるであろうことが期待できます。
先日のGTC2025では、Blackwell世代の新GPUであるNVIDIA RTX Pro 6000 Blackwellが発表されましたが、こちらはGPUメモリが96GBも搭載されており*2、こういった大容量RAMを搭載したGPUが普及するにつれてより使い勝手も良くなっていく事でしょう。