そもそもGPUとはどのようなものなのか

fadis氏:こんにちは。松林です。今日は3DグラフィックスAPI、Vulkanの近況について解説します。

Vulkanは、OpenGLを標準化していることでも知られるKhronos Groupが、OpenGLの後継として作った「GPUを操作するためのクロスプラットフォームなAPI」です。公式サイトの対応プラットフォームを見ると、Windows、Linux、Androidなどいろいろなプラットフォームが並んでいます。

今日は最新のVulkanの話をしたいのですが、そもそも「GPUコンピューティングをやったことがない」という方も多いのではないかと思います。なのでまず、GPUとはどんなもので、Vulkanを使うと何ができるのかを見ていきましょう。

2000年頃までのGPUは、メモリの内容を画面に表示する機構と、メモリに対して3Dグラフィックスを描くための専用ハードウェアをくっつけたものでした。しかし3Dグラフィックスに要求される計算が複雑になると、専用のハードウェアで高速に計算する手法は破綻します。

GPUはそうした要求に耐え得るために、特定の計算を行うハードウェアパイプラインから、汎用的な計算を行うプロセッサーへと進化していきました。そして2008年頃には、任意の計算を行うプロセッサーと、計算に使うデータを置くメモリと、画面に描画内容を送る機構という構成にたどり着きました。

これを見ると、CPUとメインメモリで計算するのと変わらないように見えます。しかし実際には、GPUはいくつかのタスクをCPUよりずっと速く計算できることから積極的に活用されています。

なぜこの方法でCPUより速い計算機が実現できるのでしょうか。

それはGPUが大量の任意の計算を行うプロセッサーを積んだデバイスだからです。

なぜGPUはCPUよりずっと速く計算ができるのか

実際のハードウェアを見てみましょう。これはGeForce RTX3080の場合です。このGPUのプロセッサーの最小単位は「Warp」と呼ばれます。Warpはデコーダ、レジスタ、ロード/ストアにfloat32個を1単位として動くベクトル演算を備えています。CPUでいえば、レジスタ値を1024bitのSIMD命令を備えたコアが1つに近い状況です。

Warpを4つ足したものに対してL1キャッシュがくっつきます。このユニットで1クロックにfloatを128個計算します。これがCPUでいうと、4コアのCPUに近い状況です。

さらにこのユニットが2つで組になって、さらにそれが6機で1組になり、さらにそれを7機並べたものが1つのパッケージに収まっています。したがって1クロックの間に、32bit浮動小数点数を10,752個計算することになります。さらにこれがSLIで接続されている場合、1クロック当たりに計算する浮動小数点数の数は、20,000個を超えます。

大量の演算機で1クロックの間に大量のデータに対して演算を行うから、個々のプロセッサーが少々遅くてもそれを補って余る性能が出る、というのがGPUです。

CPUは1クロックで計算できる数以上のデータが同時に必要

ではなぜCPUはそういうアーキテクチャにしないのかというと、この方法で高速に処理するためには、1クロックで計算できる浮動小数点数の数以上のデータが同時に必要だからです。データの数がわずかだった場合、GPUではたくさんの演算機が無駄に回ることになるので、データの数が少ないから短時間とはなりません。

このような大量のデータ並列を用意できるかどうかはタスクによるため、十分なデータ並列度があるタスクならGPUに、そうでなければCPUに投げるのがより性能の出る選択になりがちです。

このため、1スレッドを高速に実行するCPUのようなアーキテクチャも必要な一方で、同時に計算する数で勝負するGPUのようなアーキテクチャも必要になり、両者を組み合わせて使うのが一般的なスタイルです。

Vulkanを使うとできる3つのこと

さて、演算機が異常に多いプロセッサーではありますが、GPUもプロセッサーとメモリがついたデバイスでしかない以上、その操作手順はマイコンのプログラミングと大して変わりません。まずGPUに必要なデータを送り、GPU上で実行可能バイナリを実行して、最後にGPUから計算結果を取り出します。

GPUを作っているベンダーはたくさんあり、ハードウェアによって詳細な仕様は異なりますが、どのベンダーのGPUだとしても、この3つの操作をできるようにするAPI、それがVulkanです。

GPUのメモリにデータを送る

まずはGPUのメモリにデータを送りましょう。GPUは多くの場合、PCI-Expressにつながっていますが、そこからメインメモリのデータを読むことができます。ただしCPUとは異なるMMUを介してメインメモリを見ているため、CPU側で普通にmallocした領域は、PCI-Expressのデバイスからは連続した領域に見えません。

なので、CPU側のMMUとPCI-Express側のIOMMUが共に同じ物理アドレスの同じ範囲を指しているような領域を作る必要があります。この領域にコピーしたデータは、GPUからも読み書きできます。

CPUとGPUはそれぞれのキャッシュを持っていますが、両者のキャッシュの一貫性はほとんどの場合維持されません。これは、CPUが書き換える可能性があるメモリをGPUはキャッシュできない、という意味です。キャッシュを使うためには、データをさらにCPUから見えないメモリにコピーする必要があります。ディスクリートなGPUが持っている、GPUに積まれたメモリなどがこれに該当します。

まとめると、GPUのメモリにデータを送るには、3つの領域と2つのコピーが必要です。このうち1つ目のコピーはmemcpyで行えますが、GPUで行う必要がある2つ目のコピーは、専用のAPIが必要です。また、IOMMUの設定を伴うメモリと、GPUのローカルメモリの確保はmallocではできないので、これらにも専用のAPIが必要になります。

Vulkanではmallocでは確保できない特殊なメモリの確保はvkAllocateMemoryで、GPU側でのコピーはvkCmdCopyBufferで行います。ちなみに紫で示したようなデータの転送の過程で一時的に必要になる領域を、グラフィック畑では「Staging Buffer」と呼びます。

計算結果をCPUに返して、GPUのメモリから結果を取り出す

計算結果をCPUに返す時は、送る時と逆の手順でコピーをします。CPUからGPUにデータを塊でコピーするということは、CPUが書いた符号付き整数や浮動小数点数をGPUはCPUと同じように解釈できなければならないという意味です。

このためVulkanの仕様では、CPU・GPU共に符号付き整数は2の補数表現で、浮動小数点数はIEEE754で、エンディアンはCPUとGPUで同じものになっていなければならないと決まっています。なのでVulkanに対応している環境では、CPUとGPUで値をコピーして同じように解釈できることが保証されます。

VulkanのAPIを使うと、あるGPUでどんなメモリが使えるかを調べられます。今このデバイスでは、GPUのメモリに2つ、CPUに1つのヒープがあるようです。

メモリタイプはそれぞれのヒープからどんな設定のメモリを確保できるかを表します。紫の部分は特殊用途なので今は無視しますが、下のほうを見るとGPUの上にGPUだけが見られる領域、CPUの上にGPUも見られる領域、GPUの上にCPUも見られる領域など、いろいろ確保できるようになっています。

このメモリタイプを引数につけてvkAllocateMemoryを呼ぶことで、指定した振る舞いをするメモリが確保されます。

Vulkanではメモリは確保しただけでは使えず、用途を定めるオブジェクトをリバインドすることで可能になります。

vkCreateBufferで作れるバッファはそうした用途を定めるオブジェクトの1つで、メモリの内容は計算に使う汎用的なデータで、CPUから書いたとおりGPU側でも見えてほしいという意思表示をします。

先ほど確保したメモリをバッファにバインドします。これでこのメモリをバッファとして使えるようになりました。

メモリタイプのうち、CPUから見える属性がついているものは、vkMapMemoryでプロセスのアドレス空間にマップして、先頭アドレスを取得できます。CPUから見えるメモリにStaging Bufferを作り、マップしてデータを書き込みます。

Staging Bufferにデータが乗ったので、次はGPU側でその内容をGPUローカルなメモリ上のバッファにコピーさせます。これはGPUに動いてもらう必要があるわけですが、PCI-Expressにつながる多くのデバイスがそうであるように、GPUもコマンドキューを持っています。キューにコマンドを流すと、その内容が順番に受理され、実行が完了すると結果が非同期で返ってきます。

GPUのキューにはコマンドを1つずつではなく、コマンドバッファと呼ばれる塊で流します。実行結果はコマンドバッファ1つにつき1つだけ返ってきます。

GPUはしばしばキューを複数持っています。同一のキューに対するコマンドの書き込みはロックを取って排他的に行う必要がありますが、異なるキューに対する書き込みは複数のCPUから同時に行えます。

VulkanのAPIを使うと、GPUがどのようなキューを何本備えているかを調べられます。キューの中にはあらゆるコマンドを流せるものや、一部のコマンドだけを処理できるものも存在します。

よくあるのはこのようなデータ転送コマンドだけを流せるキューで、これはGPUの演算機とは独立に動くDMAが8基備わっていることを意味します。後ほど詳しく説明しますが、同一のキューに流れるコマンドと比べて、異なるキューに流れるコマンドは同期のコストが高くなります。

プロセッサーを回してデータを引っ張るより、DMAでデータを引っ張ったほうが演算機を演算に専念させられます。しかし、DMAの完了に演算側が気づくコストが高くなるためタイミングが重要で、小さいデータはプロセッサーに引っ張らせたほうがよいこともあります。

コマンドバッファはハードウェアによっては専用のメモリに置かなければならないことがあるため、Vulkanでは通常のメモリアロケーターとは別にコマンドバッファを割り当てる専用のメモリプールが用意されています。

コマンドプールからアロケートしたコマンドバッファにvkCmdCopuBufferを積んで、VkQueueSubmitでキューに流すとGPU側でコピーが実行されます。

フェンスオブジェクトを作ってサブミット時に渡しておくことで、CPU側で完了通知を受け取れます。これでGPUのメモリにデータを送る、GPUのメモリから結果を取り出す、はできるようになりました。

GPU上で実行可能バイナリを実行する

残るのは、GPU上で実行可能バイナリを実行する、です。このGPU側で実行するコードは歴史的な理由からよく「シェーダ」と呼ばれています。

GPUの命令セットはベンダーごとに異なり、GeForceかRADEONかというのは、CPUでいうとx86かARMかくらいの違いがあります。さらにGPUはプロセッサーを簡素に保たないと成立しないため、同じベンダーのGPUであっても、前の世代であまり役に立たなかった命令を容赦なく削除します。これはAMD GCNとAMD RDNA2のベクタ演算命令セットのdiffを取ったものですが、かなりの数の命令が削除されていることが読み取れます。

つまり、あるGPU向けの実行可能バイナリを流して実行することは可能ですが、そうするとベンダーの違うGPU、世代の違うGPUで動かなくなってしまいます。長期にわたって同じハードウェアが使われ続ける家庭用ゲーム機では、それでも困らないので、この方法が用いられています。

Vulkanの前身であるOpenGLでは、「GLSL」と呼ばれる高級言語で書かれたシェーダを、実行時にGPUのドライバに渡してコンパイルしていました。つまり、ソースレベルで互換を取ることで複数のGPUで動くようにしていたのです。この方法は確かに機能しますが、実行時にシェーダをコンパイルするのに時間がかかりすぎるという問題がありました。

コンパイラの処理の手順は大きく分けて4段階です。まず字句解析と構文解析を行って抽象構文木を作ります。それに対してターゲット非依存の最適化、続いてターゲット固有の最適化を行い、最後にターゲットの実行可能バイナリに変換します。

Vulkanで用いられるシェーダ「SPIR-V」

紫で示した部分はGPUによって異なる処理なので、実行時に行う必要がありますが、オレンジで示した部分はどのGPUが相手でも同じなので、事前に片付けても問題ないはずです。そこでターゲット非依存の最適化までを済ませた抽象構文木を、バイナリ形式でシリアライズしてファイルにしようというアイデアが登場しました。これがVulkanで用いられるシェーダの形式、SPIR-Vです。

VulkanではSPIR-VをvkCreateShaderModuleに渡すことで、GPUの実行可能バイナリを生成します。SPIR-Vは「GLSLC」と呼ばれるKhronos Groupから提供されているコンパイラで、GLSL(OpenGL Shading Language)やHLSL(High Level Shading Language)といった高級言語から生成できます。

(次回へつづく)