以下の内容はhttps://kaigai.hatenablog.com/entry/2025/04/02/003912より取得しました。


PG-Strom v6.0特集:Large Tables JOIN(シングルGPU編)

PG-Strom v6.0をリリースしました。

  • GPU-Sortと一部のWindow関数対応
  • マルチGPUのPinned Inner Buffer対応
  • Arrow_Fdwの仮想列機能
  • GPUでの完全な集計値の生成

といった、いくつかの重要機能を含むリリースで、特にGPU-Sortによって新しいワークロードへの対応が広がったという事でバージョン6.x系列としました。
その主要機能について、何回かに分けて解説していきたいと思います。

GPU-Joinの立ち上がり、遅くね?

以下の実行計画を見てみてください。

ssbm=# explain
select c_city, s_city, d_year, sum(lo_revenue) as revenue
from customer, lineorder, supplier, date1
where lo_custkey = c_custkey
and lo_suppkey = s_suppkey
and lo_orderdate = d_datekey
and c_nation = 'UNITED STATES'
and s_nation = 'UNITED STATES'
and d_year >= 1992 and d_year <= 1997
  group by c_city, s_city, d_year;
                                                   QUERY PLAN
-----------------------------------------------------------------------------------------------------------------
 HashAggregate  (cost=13553319.83..13558788.58 rows=437500 width=58)
   Group Key: customer.c_city, supplier.s_city, date1.d_year
   ->  Gather  (cost=13502916.18..13548944.83 rows=437500 width=58)
         Workers Planned: 2
         ->  Parallel Custom Scan (GpuPreAgg) on lineorder  (cost=13501916.18..13504194.83 rows=437500 width=58)
               GPU Projection: pgstrom.psum(lo_revenue), c_city, s_city, d_year
               GPU Join Quals [1]: (s_suppkey = lo_suppkey) [plan: 2500011000 -> 97750430]
               GPU Outer Hash [1]: lo_suppkey
               GPU Inner Hash [1]: s_suppkey
               GPU Join Quals [2]: (c_custkey = lo_custkey) [plan: 97750430 -> 3900243]
               GPU Outer Hash [2]: lo_custkey
               GPU Inner Hash [2]: c_custkey
               GPU Join Quals [3]: (d_datekey = lo_orderdate) [plan: 3900243 -> 3344810]
               GPU Outer Hash [3]: lo_orderdate
               GPU Inner Hash [3]: d_datekey
               GPU Group Key: c_city, s_city, d_year
               Scan-Engine: GPU-Direct with 2 GPUs <0,1>
               ->  Parallel Custom Scan (GpuScan) on supplier  (cost=100.00..72287.05 rows=162912 width=17)
                     GPU Projection: s_city, s_suppkey
                     GPU Scan Quals: (s_nation = 'UNITED STATES'::bpchar) [plan: 9999718 -> 162912]
                     Scan-Engine: GPU-Direct with 2 GPUs <0,1>
               ->  Parallel Custom Scan (GpuScan) on customer  (cost=100.00..60041.62 rows=498813 width=17)
                     GPU Projection: c_city, c_custkey
                     GPU Scan Quals: (c_nation = 'UNITED STATES'::bpchar) [plan: 30003780 -> 498813]
                     Scan-Engine: GPU-Direct with 2 GPUs <0,1>
               ->  Parallel Seq Scan on date1  (cost=0.00..69.55 rows=1289 width=8)
                     Filter: ((d_year >= 1992) AND (d_year <= 1997))
(27 rows)

これはStar Schema BenchmarkのQ3_2ですが、実行計画のほぼ全体がGpuPreAgg内のJOINで完結しています。
最もサイズの大きな lineorder テーブルのスキャンを軸に、予めsupplier、customer、date1の各テーブルを読み出して構築したハッシュ表を用いてGPU上で並列のHash-Joinを実行する事が分かります。

このGpuPreAggは内部的にどのような動作をするかというと、CustomScan APIを通じてこのノードに処理が移ると

  1. ExecProcNodeを呼び出してsupplier表のスキャンを実行し、結果をメモリ上にバッファ
  2. ExecProcNodeを呼び出してcustomer表のスキャンを実行し、結果をメモリ上にバッファ
  3. ExecProcNodeを呼び出してdate1表のスキャンを実行し、結果をメモリ上にバッファ
  4. バッファしたこれらの結果から、GPU-Joinで使用するハッシュ表を共有メモリ上に構築する
  5. GPU-Serviceプロセスにセッションをオープンし、共有メモリ上のハッシュ表をGPUメモリ上にロードする
  6. lineorder表を読み出しつつ、順にGPU-Joinを実行しsupplier、customer、date1の各テーブルと結合処理を行う
  7. GPU-PreAggで集計処理を行い、集計値を結果バッファに書き込む
  8. lineorder表の読み出しが終わると、結果バッファをPostgreSQLバックエンドプロセスに戻す

そこそこの手順を踏んでいる事が分かります。
中でも、(1)と(2)では下位Scanノードの読み出しを行っているのですが、ここではGPU-Scanが使われています。
つまり、supplierやcustomer表をスキャンするために一度GPUへこれらのテーブルのデータをロードしているにも関わらず、一度CPU側へこれを書き戻し、さらにCPUサイクルを消費してこれをバッファにコピーしているわけです。

無駄じゃね?🙄

Star Schema Benchmarkのように、圧倒的に大きな lineorder(この例では876GB)の他は、customer(4.0GB)、supplier(1.3GB)、date1(416kB)程度しかないようなワークロードでは大きな問題になりませんし、そもそもがHash-Joinというアルゴリズムは、サイズが非対称なテーブル同士のJOINを前提としています。少なくともメモリに乗らないとどうしようも無いわけですし。

しかし、バッチ系の処理を中心としてサイズの大きなテーブル同士をJOINするという処理は珍しくないですし、そういった場合に毎度GPUからCPUへとデータを書き戻し、CPU側のメモリを大量に消費するというのは、余分な処理コストと、メモリ消費という2つの点であまりよろしくありません。

Pinned Inner Bufferとは?

PG-StromがSQLワークロードを処理する流れについて簡単に整理してみましょう。
しばしば『GPUってメモリxxGBしか無いのに、xxTBのテーブルを処理できるの?』と聞かれる事があるのですが、その理由はPG-Stromのタスク実行方式にあります。
GPU-JoinやGPU-Scanの場合、読み出すべきテーブル(GPU-Joinの場合はOUTER側のテーブル)を約64MB毎の単位で分割し、この処理単位をGPUに送信してはGPUでこれを評価して、処理結果をCPU側に書き戻します。これを多数のワーカースレッドで並行して行うため、ワーカーAがストレージからの読み出しを行っている間にワーカーBはGPUで処理を実行し、またワーカーCは結果をPostgreSQLバックエンドに書き戻す・・・というように、リソースを極力遊ばせないようにしています。そして、この時に使ったバッファは次の64MBブロックの処理に再利用できるので、テーブルの大きさそれ自体は大きな問題とはなりません。

一方、GPU-PreAggの場合は集計用のバッファをGPU上に置いておき、同様に64MBのブロックからデータを読み出しつつも、この集計バッファを更新した後は処理結果を戻さず、テーブルのスキャンが終わってから一括で処理結果をPostgreSQLバックエンドに戻します。
これは多くの場合、集計クエリが元データを大きく縮減するという特性があるためですが、もちろんカーディナリティの高い重複排除クエリのように読みが外れて事故を起こす事もあり得ます。

さて、ここで使用する結果バッファのデータ形式は、実はGPU-Joinで使用するハッシュ表のデータ形式と同一です。
という事は、GPU-Joinの配下でINNERハッシュ表を作る際に、結果バッファをGPU側に留置しておき(CPU側に戻さず)、それを次のGPU-Joinで使用する事ができれば、余分なデータの移動やCPUによる整形が必要なくなるのではないかと考えました。これがPinned Inner Buffer機構です。

Pinned Inner Bufferの効果を確認するには、もう少し大きなテーブル同士のJOINが必要になるので、ここではTPC-Hのデータセットを利用します。
SF=1000の場合にlineitemは882GB、ordersは205GBですが、ここでは条件句o_orderdate > '1997-05-01'によってInner Bufferのサイズを調整できるようにしています。
TPC-Hのテストクエリそれ自体には、Large Table同士のJOINを行うものはありませんでしたので、ここではデータセットのみ使っています。)

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 > '1997-05-01'
        group by l_shipmode, o_orderpriority;
                                                                            QUERY PLAN

------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Gather  (cost=20248004.34..20248008.55 rows=35 width=59) (actual time=56729.388..56729.515 rows=35 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   ->  Result  (cost=20247004.34..20247005.05 rows=35 width=59) (actual time=56721.837..56721.847 rows=12 loops=3)
         ->  Parallel Custom Scan (GpuPreAgg) on lineitem  (cost=20247004.34..20247004.52 rows=35 width=59) (actual time=56721.835..56721.842 rows=12 loops=3)
               GPU Projection: pgstrom.psum(l_extendedprice), l_shipmode, o_orderpriority
               GPU Join Quals [1]: (l_orderkey = o_orderkey) [plan: 2500102000 -> 480716900, exec: 533500 -> 82944]
               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 GPU0; direct=115517748, ntuples=533500
               ->  Parallel Custom Scan (GpuScan) on orders  (cost=100.00..4038700.34 rows=120172200 width=24) (actual time=10926.643..10926.645 rows=-1 loops=3)
                     GPU Projection: o_orderpriority, o_orderkey
                     GPU Pinned Buffer: nitems: 285537432, usage: 23.40GB, total: 27.74GB
                     GPU Scan Quals: (o_orderdate > '1997-05-01'::date) [plan: 1499973000 -> 120172200, exec: 1500000000 -> 285537432]
                     Scan-Engine: GPU-Direct with GPU0; direct=26843246, ntuples=1500000000
 Planning Time: 4.135 ms
 Execution Time: 56730.069 ms
(19 rows)

GpuScan on ordersのパラメータ出力を見てください。
GPU Pinned Buffer: nitems: 285537432, usage: 23.40GB, total: 27.74GBという表示が見えます。これこそがPinned Inner Bufferを使用した事を示す出力で、ここで用いたGPUである NVIDIA A100 (40GB; PCI-E) の3/4近くのサイズを占めるバッファを確保し、これをGPU-Joinで利用したという事になります。

次に、クエリを実行中の I/O の出力の様子を見てみる事にします。I/Oの様子を観察する事で、今、処理がどの辺まで進んでいるのかという事を推測する事ができます。

まず、Pinned Inner Bufferのない従来のGPU-Joinを実行したケースのI/Oの様子です。

面白い事に、クエリの実行開始から25~50秒付近の約25秒、I/Oが停止している時間帯があります。
これがordersテーブルから読み出したデータをInner BufferにCPUで再編するための時間で、2.8億行ともなればそれなりに時間を要している事が分かります。また、前半のordersテーブルの読み出しも10GB/s程度とGpuScanを使っている割には遅く、これはスキャンした結果を受け取るPostgreSQLバックエンドプロセス側でのメモリコピーが間に合っていないと解釈すべきでしょう。

そして、Pinned Inner Bufferを使った場合のI/Oの様子がこちらです。
先ほどのケースにあったような切れ間がなく、概ね20GB/s程度のスループットでLarge Table同士のJOINを処理している事が分かります。

ちなみに、PostgreSQLでのHash-Joinの様子はこのようになっています。
6.0GB/s前後の読み出しが35秒程度持続し、その後は2.8GB/sのスループットでの処理が続いています。
これはおそらく、6.0 * 35 = 210GB なので orders テーブルの読み出しに、そして後半の時間帯はストレージの読み出しよりもむしろ巨大なハッシュ表を使ったHash-Joinの処理がCPUネックとなっていると思われます。

Pinned Inner Buffer の注意点

良い事ばかりに思えるPinned Inner Buffer機能ですが、注意点もあります。

GpuScanの処理結果をGPUメモリに留置するというのが本機能のポイントですが、では、GPUでは処理できなかったデータというのはどうなるのでしょうか?
何を言っているかというと、典型的にはTOAST化された可変長データを参照した場合、などが含まれます。
非常に長い(目安として2kB以上)の可変長データの場合、PostgreSQLはこれを外部の隠しテーブルに格納し、参照があった時にこれを外部テーブルから都度読み出して元の大きさの可変長データを復元します。しかし、GPUに64MB単位のブロックを転送して処理を行っている都合上、そもそもGPUから外部テーブルを参照して処理を行うことなどできません。

そこでPG-StromはCPU-Fallbackという仕組みを用いて、GPUで処理できなかった行はCPUに書き戻して再実行する(= エラーにしない)仕組みがあるのですが、CPUにデータを書き戻すという振る舞いがPinned Inner Bufferと相性が悪く、Pinned Inner Bufferを使用する際にはCPU-Fallbackをoffにセットしなければいけません。
これは副作用のある振る舞いですので、PG-Strom v6.0においてはCPU-Fallbackはデフォルトで有効のままです。したがって、Pinned Inner Bufferを使用するには、明示的にこれらの設定を変更する必要があります。
上記のクエリを実行した際には、以下のような設定を行っています。

tpch=# set pg_strom.cpu_fallback = off;
SET
tpch=# set pg_strom.pinned_inner_buffer_threshold = '2GB';
SET

なお、TOASTメカニズムに関しては、なんと15年前(!)にブログに書いています。
が、実はこの当時と仕組みは変わっていません。凄いですね。
kaigai.hatenablog.com




以上の内容はhttps://kaigai.hatenablog.com/entry/2025/04/02/003912より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14