モバイルGPUの特徴

fadis氏:モバイルGPUというのは、スマートフォンのSoCに統合されているGPUのことです。GPU専用には大きなメモリを積めないので、CPUとメモリを共有していますが、このメモリとGPUをつなぐバスが多くの場合、非常に細いという特徴があります。

モバイルGPUは、細いRAMから頻繁にデータを読み書きしないで済むように、太いバスでつながったSRAMを内蔵しています。ただし、SRAMの容量はとても小さく、1画面分のレンダリング結果も置けません。したがって、レンダリング結果はあくまで細いバスの向こうに置くことになります。

モバイルGPUは画面をタイル状に区切り、1つのタイルをレンダリングするのに必要なデータだけをSRAMに持っていって計算を行います。この操作を各タイルで順番に行っていくことで、計算の途中で細いバスにデータを流さずにレンダリングできるのです。

バリアを使ってマルチパスレンダリングを行うと、これが台無しになります。なぜなら、1つ目のパイプラインの計算結果を、すべてメインメモリに吐いてから2つ目のパイプラインがそれを読んで動く、という流れになるからです。

レンダーパス内の複数のパイプラインの間には、データの依存関係を設定できます。ただし、バリアで依存関係を作る場合とは異なり、レンダーパス内でのデータの受け渡しでは(x,y)の位置をレンダリングしている時、別のパイプラインからきたデータの(x,y)の位置以外は読めない制約があります。これを受け入れられる場合、複数のパイプラインの処理を結合することで、メインメモリへのアクセスを抑えられます。

先ほどの例の場合、影判定と画像処理は前のパイプラインの結果の任意のピクセルを読める必要があるため、バリアを置くしかありません。しかし、Gバッファの生成から照明の総和までは、パイプラインは1つのレンダーパスにまとめられます。このような最適化を行うために、グラフィックスパイプラインはパイプライン単位ではなく、レンダーパス単位でキューに積むようになっています。

3Dグラフィックスを画面に描いて表示する

3Dグラフィックスをレンダリングできたら、次は表示したくなるものです。多くのGPUは、メモリの内容を画面に送るための仕組みを持っていますが、大抵の環境ではこのハードウェアはX Window System、Wayland Compositor、Windows DWMといったコンポジタが握っていて、アプリケーションが直接描くことはできません。なのでまず、コンポジタに対して描画内容を伝えるためのサーフェスを要求します。

プラットフォーム依存の部分なので、プラットフォームによっていろいろなハンドラが返ってきます。あるコンポジタがVulkanに対応しているということは、そのコンポジタのネイティブのハンドラからVulkanのVkSurfaceに変換できるという意味です。コンポジタごとにこれを行うための拡張が用意されています。

サーフェスにレンダリング結果が入ったメモリをつければ、コンポジタがそれを読んで画面にレンダリング結果を表示してくれます。しかし、このためのメモリが1つしかないと、アプリケーションが描いている途中の状態をコンポジタが読んで表示してしまう恐れがあります。

なので、コンポジタと共有するメモリはたくさん用意します。新しいレンダリング結果ができたら、サーフェスに結びつけるメモリを新しいものに切り替え、要らなくなった古いメモリに対して次のレンダリングを行います。このメモリが連なってグルグル回る構造を「スワップチェーン」と呼びます。

スワップチェーンのイメージは、プラットフォームが許容する範囲で好きな枚数を指定できます。スワップチェーンから取り出せるのは、最初からメモリが割り当てられたイメージです。これは、コンポジタが解釈できるレイアウトにしかならないように設定されています。

このイメージに対してレンダリングを行うわけですが、グラフィックスパイプラインは色だけでなく、深度とステンシルを書き込むイメージを必要としています。これらをセットにしたものは「フレームバッファ」と呼ばれます。スワップチェーンからもらえるのは色を書き込むイメージだけなので、深度とステンシルを書き込むイメージは自分で用意します。

フレームバッファにイメージのどの範囲を使わせるかを指定するイメージビューを設定し、グラフィックスパイプラインを実行します。描けたらイメージをコンポジタに送りましょう。

スワップチェーンからもらったイメージは、コンポジタ側でもう使っていないかどうかをチェックしてから書く必要があります。キューの中でのコマンド同士の同期にはバリアを使いましたが、キューの外での出来事や、異なるキューに流れるコマンド同士の同期にはセマフォを使います。

今回の場合、コンポジタはコンポジタ側でGPUのキューにコマンドを流しているはずなので、アプリケーション側のキューとの間で同期を取るためには、バリアではなくセマフォが必要です。一般にGPUのスケジューラに対する指示であるバリアと比べて、メモリを使った同期を行うセマフォは高コストです。一連の手順で3Dグラフィックスを画面に描くことができます。

Vulkan1.1の概要

さて、今日の本題は「いまどき」のVulkanです。ここまで話した内容は、2016年のVulkan1.0に備わっていた機能ですが、そこから5年の間にVulkanはより便利に、より高機能になりました。まずは2018年のVulkan1.1から見てみましょう。

Vulkan1.1では、16bitストレージのサポートが追加されました。これはバッファにuint16_tの配列を書いて、シェーダからuint16_tの配列として読み出せるようにするものです。

GPUによっては2バイト間隔で詰まった16bit整数のloadとstoreができないかもしれないので、この機能が使えるかどうかを調べられるようになりました。これに限らず、Vulkan1.1以降で追加されたほとんどの機能は、GPUが対応しているかどうかをまず調べてから使う仕様になっています。

この機能の追加で、半精度浮動小数点数も読み書きができるようになりました。GPUのプロセッサーは32個から64個程度の浮動小数点数を一度に計算するSIMD命令を備えています。

シェーダを実行すると、32スレッド分が1つのSIMD命令で処理されることになります。この32スレッドは「Subgroup」と呼ばれ、個々のスレッドが独立した計算をしているうちは、あまり意識する必要はありませんでした。

例えば通常の加算は、このように2つのベクターaとbに対応する要素で加算を行い結果を出力するので、個々のスレッドは自分が担当する要素だけを見ていればよく、隣でどんな計算が行われているかは重要ではありませんでした。

Vulkan1.1で水平演算が追加されたことで、このへんの事情が変わります。subgroupAddは同じsubgroup内で計算されているaの値をすべて足したものが結果に書き込まれます。

他にも階段状に加算を行うInclusiveAddとExclusiveAdd、そして隣接するn個の要素の間で加算を行うClustereAddなどが登場しました。ここでは加算だけを例に挙げていますが、実際には加算・乗算・最小・最大・AND・OR・XORに対応する関数が用意されています。

Shuffleはベクタaの要素の並び順をベクタbのとおりに変更します。Broadcastは結果のすべての要素をaの指定した要素の値にします。QuadBroadcastは4要素ずつのBroadcastを行います。

「Subgroupのサイズがいくつか」が重要になったので、デバイスの情報から取得できるようになりました。さらにGPUによってはすべての水平演算をサポートできないかもしれないため、使える水平演算を調べられるようになりました。

Vulkanでは物理デバイス1つを表すハンドラに、使用するVulkanのバージョンや拡張のリストを渡して論理デバイス、VkDeviceを作ります。物理的なデバイスが特定の拡張やより新しいバージョンのVulkanをサポートしていたとしても、論理デバイスは指定されたバージョンの指定された拡張だけを持つデバイスであるかのように振る舞います。

GPUが2枚挿さっているホストで、両方のデバイスを使いたいとします。Vulkan1.0では、両方の論理デバイスを作って2つのGPUを別々に操作します。

複数のGPUはNVLinkのようなバスで接続されて、より密に連携できる場合があります。Vulkan1.1では、このような場合に複数の物理デバイスから成るDevice Groupに対して、論理デバイスを1つ作ることができます。このデバイスのキューに対してコマンドを流すと、Device Group内のすべてのGPUで同じコマンドが実行されます。

コマンドバッファをキューに流す時、実行するGPUを制限できます。こうすると複数のGPUにコマンドを個別に投げられますが、単純に2つの独立した論理デバイスがある場合との大きな違いとして、異なる物理デバイスで実行しているコマンド同士をバリアで同期させることができます。

VRヘッドセットでは、ヘッドセットのレンズに起因する像の歪みを打ち消すようにレンダリング結果を歪ませる必要があります。この時、まっすぐな状態でのレンダリング結果の中心付近は、歪んだレンダリング結果に大きく表示されるため高い解像度が必要ですが、端のほうは小さく表示されるので高い解像度でレンダリングしても無駄になります。

場所によって必要な解像度が異なる状況で効率よくレンダリングを行うために、「端のほうだけ小さくレンダリングしておこう」というアイデアが出てきました。これを実現するのがVulkan1.1で追加されたMultiViewです。

MultiViewでは、同一のレンダーパス内の設定が異なる複数のパイプラインに、同じ頂点配列を流して一斉に描画を行います。同じイメージの異なる範囲を指すイメージビューをフレームバッファにすることで、各パイプラインの出力を合体させることを可能にします。あとは別のレンダーパスで変形させます。

Vulkan1.1で追加されたProtected Memoryは、GPUに1度突っ込んだら取り出せない制限のかかったメモリを確保します。これは、「コピープロテクトのかかった画像や動画がGPUのメモリから読み出されるのを防ぎたい」という方面からの需要があるようです。

(次回へつづく)