現在の PG-Strom のアーキテクチャは、PostgreSQLの各バックグラウンドプロセスが個別にCUDAコンテキストを作成し、GPUデバイスメモリを作るという構成になっている。
これは、設計の単純化、特にエラーパスのシンプル化により、全体的なソフトウェアの品質が低い時には開発効率の観点からは意味のあるデザインではあるが、一方で、以下のような問題も同時に抱えている。
- CUDAコンテキストの初期化には200ms~400ms程度のオーバーヘッドを要するため、数秒で終わる程度のクエリ処理ではこのコストは無視できない。
- 一方、CUDAコンテキストの初期化・破棄の回数を抑えるために、一度構築したCUDAコンテキストをキャッシュしておくと無駄にGPU RAMを保持し続ける。CUDAコンテキストあたり~90MB程度のGPU RAMを消費する*1ので、同時並行セッション数が増えてくるとワーキングメモリが取れなくなる。
- GPUリソースの過剰割当て。あるバックエンドが巨大バッチの実行中にGPU RAMを使い過ぎ、その後、別のセッションが新たに始まっても、巨大バッチ実行中のバックエンドからGPU RAMを奪う事ができない*2。
なので、ある意味先祖還りなのだが、上の図のようにGPUやCUDAに関わる部分だけを別プロセスに分割して、GPUリソースの管理や同時実行数のコントロールを行えるようにしたい。特に、使わなくなったGPUリソースを直ちに解放できるようにする事と、CUDAコンテキスト作成のオーバーヘッドを軽減する事は、次のPostgreSQLでCPU+GPUハイブリッド並列を実装するにあたって必須とも言える作業である。
とはいえ、PostgreSQLバックエンド ⇔ CUDAサーバ間のデータの受け渡しという新たな問題が出てくる。
- PostgreSQL起動時に獲得する静的共有メモリ領域はサイズが固定であるので、サイジングが難しい。少なすぎれば途中で out of memory だし、大きすぎれば他に使うべきメモリ領域を圧迫する。
- 動的に共有メモリを獲得した場合、複数のバックエンド間でアドレスを共有できない。したがって、一般的に、データの受け渡しには共有メモリセグメント先頭からのオフセット表現を用いるか、
mmap(2)
のMAP_FIXED
を使って固定アドレスでセグメントがマッピングされるよう強制するしかない。 - しかし
mmap(2)
でMAP_FIXED
を使用した場合でも、ポインタを受け取った先でその共有メモリセグメントが既にマップされているかどうかは毎回バリデーションが必要で、これならオフセット表現と大差ない。
....という所で悶々としていたのだが、この辺の問題を解決する方法を考えてみた。
mmap(2)
のMAP_FIXED
を使って共有メモリセグメントをマップするアドレスは固定にする。- 各共有メモリセグメントは各々1GBなどの固定長。各セグメントの状態だけは静的共有メモリ上に保持。
SIGSEGV
やSIGBUS
シグナルハンドラを使用して、これらの共有メモリセグメントへの参照が必要になった時点でmmap(2)
する。- 実際に
mmap(2)
するまでは、使用する可能性のある仮想アドレス空間にはPROT_NONE
でダミーの領域をマップしておく。
....なーんでもっと早く気付かなかったかなぁ?という位単純な話だが、図を使って説明すると以下のようになる。
初期状態。PostgreSQLの各バックエンドはpostmasterと呼ばれる親プロセスからfork(2)するが、その時点で、オレンジ色の静的共有メモリと、PROT_NONEでmmap(2)した仮想アドレス空間を持つよう初期化されている。
各共有メモリセグメントは、segment_id
とrevision
を持ち、これによって共有メモリセグメントの存在・不存在の状態を管理する。mmap_ptr
はこれをマップすべき領域の仮想アドレスである。
で、各バックエンドはfork(2)して作られるので、この仮想アドレス空間を引き継ぐことになる。
次に、あるバックエンドが共有メモリセグメントを作成し、それを自分の仮想アドレス空間にマップする。
共有メモリアロケータは、ここにより小さなメモリブロック(chunk
)を割り当てる事ができるが、この時点では共有メモリセグメントはまだ他のバックエンドにmmap(2)されていないため、このポインタは不可視である。
では、このポインタを他のバックエンドに渡した時に、どのような振る舞いを見せるか。
他のバックエンドでこのポインタの参照はSIGSEGV
を引き起こす。デフォルトではプロセスのクラッシュを引き起こすが、シグナルハンドラを追加してやる事で当該ポインタを含む共有メモリセグメントをmmap(2)
する事ができる。
そうすると、ポインタを受け取ったバックグラウンドの側でもその領域が可視となる。
ポインタで参照されていた領域があたかも初めからそこに存在したかのように振る舞う事ができるので、『共有メモリセグメントを参照する可能性のあるポインタを毎回バリデーション…』といった、面倒でバグの温床となりがちなプログラムを書かずに済む。
まず手始めに、この仕組みをCUDAプログラムのキャッシュに適用してみた。
このコードは、PG-Stromが自動生成したCUDAプログラムをビルドして生成されるバイナリイメージを共有メモリ上に残しておき、次に同じクエリを実行する際のコンパイルを省略するためのもの。
CUDAプログラムのコンパイルはバックグラウンドワーカーにより非同期で行われるため、共有メモリ上に置かれている
postgres=# EXPLAIN ANALYZE SELECT count(*) FROM t0 NATURAL JOIN t1; dmaBufferAttachSegmentOnDemand: pid=24048 got Segmentation fault, then attached shared memory segment (id=0 at 0x7de0d6124000, rev=1) QUERY PLAN ------------------------------------------------------------------------------ Aggregate (cost=3102815.03..3102815.04 rows=1 width=0) (actual time=10407.955..10407.955 rows=1 loops=1) -> Custom Scan (GpuPreAgg) (cost=14139.24..2872442.40 rows=256 width=4) (actual time=4249.929..10407.925 rows=3 loops=1) Reduction: NoGroup -> Custom Scan (GpuJoin) on t0 (cost=10139.24..2852814.83 rows=100000080 width=0) (actual time=71.376..9900.973 rows=100000000 loops=1) GPU Projection: Outer Scan: t0 (actual time=11.080..5052.385 rows=100000000 loops=1) Depth 1: GpuHashJoin, HashKeys: (t0.aid) JoinQuals: (t0.aid = t1.aid) Nrows (in:100000000 out:100000000, 100.00% planned 100.00%) KDS-Hash (size: 9.16MB planned 13.47MB, nbatches: 1 planned 1) Inner Buffer: (13.47MB), DMA nums: 150, size: 2020.76MB -> Seq Scan on t1 (cost=0.00..1935.00 rows=100000 width=4) (actual time=0.019..13.689 rows=100000 loops=1) Planning time: 14.645 ms Execution time: 10774.832 ms (14 rows)
以下のようにデバッグメッセージが出ており、SIGSEGV
をシグナルハンドラで受け取り、
結果、共有メモリセグメントをオンデマンドでマッピングした事を示している。
dmaBufferAttachSegmentOnDemand: pid=24048 got Segmentation fault,
then attached shared memory segment (id=0 at 0x7de0d6124000, rev=1)
めでたしめでたし。