SSD-to-GPU Peer-to-Peer DMAとバッファ管理(その2)

前回の続き。

PCI-E接続のSSDからのP2P DMAでCPU/RAMを介さずにGPU RAMへデータを転送するという要素技術自体は目新しいものではない。

かつてFusion-io(現: SunDisk)もやっていたし、NVMe規格に準拠したものであれば標準のドライバに少し手を加えてP2P DMAを実行する事も可能である。実際にやっている人もいる。

問題は、PostgreSQLでの利用シーンを考えた時に、常にストレージからのP2P DMAを実行すればよい、というワケではなく、バッファにキャッシュされたデータの扱いや並行プロセスとの相互作用を考慮して、矛盾なく、かつ、性能的にもリーズナブルである方法を考えねばならない。

先ず、大前提として考えねばならないのが、PostgreSQLの追記型アーキテクチャとMVCC(Multi-version concurrency control)の仕組み。
かいつまんで言うと、各レコードには『そのレコードを作成した人のトランザクションID(xmin)』と『そのレコードを削除した人のトランザクションID(xmax)』という値が記録されており、参照側トランザクションIDとの大小関係や、これらのトランザクションがcommitされているか否かといった情報を元に、レコードの可視/不可視を判定する。

これらのルールはトランザクション分離レベル(READ COMMITTEDやREPEATABLE READ)によって異なるが、それなりに複雑なロジックを処理するのでできれば避けたい。(さらに言えば、同時並行で動いているトランザクション状態にも依って判定が変わるため、GPUで処理する事はできない。)

そこで使われるのが visibility map と、all_visible フラグ。

f:id:kaigai:20160220123541p:plain

PostgreSQLでは8KB~32KBの大きさのブロック単位でデータを管理しており、ブロックに包含されるレコードが全て、あらゆるトランザクションから明らかに可視である時には all_visible フラグがセットされる。このフラグが立っている時には、個別レコードのMVCCチェックは必要ない。
さらに、これはブロック自体とは別管理の visibility map(VM) と呼ばれるデータ構造と連動しており、VMのビットが立っているブロックは、(ブロック自体を読み込まなくても)all_visibleフラグがセットされている事が分かる。*1

そもそもGPUではall_visible=0であるブロックからレコードを抽出できないので、all_visible=0であるブロックはCPU側でレコードを抽出してRAM->GPUへと転送しなければならない。したがって、この時点でSSD-to-GPU P2P DMAの対象にできるのはall_visible=1のブロックである事が分かる。*2

VMの状態から次に読もうとするブロックがGPUでの処理が可能である事が分かった。次に、このブロックが既にPostgreSQLのバッファに載っているかどうかを確認する。
NVMe SSDは確かに高速なデバイスではあるが、RAMと比較すると文字通り"桁違いの"遅さであるので、同じPCI-Eバスを介してデータ転送を行うのであれば、普通にRAM->SSDへのDMAを行った方が合理的である。

したがって、SSD-to-GPU P2P DMAの対象となるブロックは ①all_visibleフラグが立っている ②まだshared_bufferにロードされていない という条件を満たすものである事が分かる。

f:id:kaigai:20160220123545p:plain

さて、並行するプロセスが介在する場合を考える。ある時点で対象となるブロックがshared_bufferに載っていない事が分かり、P2P DMAのリクエストをキューに入れたとする。しかし、それとはお構いなしに、並行プロセスが当該ブロックをOSからロードし、更新処理を行ってしまうかもしれない。
ただし、この事自体は問題とはならない。更新されたレコードは、P2P DMAを行っているトランザクションが開始した時点では未だコミットされておらず、あたかも存在しないものであるかのように扱っても構わないからである。
したがって、OS buffer/Storageに保持されている古いバージョンのブロックをGPUへ転送し、そこで処理を行っても問題とはならない。

f:id:kaigai:20160220123555p:plain

注意しなければならないのは、並行プロセスによって更新されたレコードが、P2P DMAの完了までにOSに書き戻される場合。

f:id:kaigai:20160220144953p:plain

PostgreSQLのストレージ書き込み(smgrwrite)はBuffered Writeなので、書き出された内容はまずOSのPage Cacheに保持される。これは単なるメモリコピーであるが、Page CacheからGPU RAMへとDMA転送を行っている途中にPage Cacheが更新され、旧バージョンと新バージョンが混じり合った状態のデータがGPUに転送されてしまうと最悪である。

ファイルシステムにもよるが、write(2)システムコールの処理中は inode->i_mutex により排他ロックを取る事になっているようなので、不完全な状態のデータを使用する事を避けるには、P2P DMAの実行開始*3から転送完了までの間は、NVMeドライバの側でも inode->i_mutex を取ってやらないとダメ。

さらに、inode->i_mutex で排他処理を行った場合でも、P2P DMAの実行より前に、並行プロセスによって更新済みページが既にOSバッファに書き出されたという場合が考えられる。このようなケースでは、GPU側でまずブロックの all_visible フラグをチェックし、GPU RAMに転送された時点でこれが all_visible=0 にクリアされていた場合にはいったん諦め、CPU側でMVCCチェックと改めてRAM上に確保したDMAバッファにレコードを積んでおく事を要求する。

そもそも、P2P DMAリクエストから転送完了までのわずかな時間の間に、並行プロセスが当該ブロックをOSから読み出し、更新を行い、それをまたOSバッファに書き戻すという一連の処理を実行する頻度はかなり低いと想定される上に、更新済みブロックは並行プロセスの手で既に shared_buffer にロードされているので、この再実行によって新たにI/Oの負荷が発生する事はない。

気を付けなければならないのは、OSのPage Cacheへの書き出しとDMAの処理がカチ合わないよう、ここだけはきちんと排他処理を踏んでやる事である。

*1:これはIndexOnlyScanでも使われており、PG-Stromとは無関係に元々ある機能

*2:とはいえ、一般的な業務アプリでは90%の行は更新されない(by SAP)らしいので、通常はall_visible=1となるブロックが大半であろう。

*3:リクエストキューに積んだ時点である必要はない