プログラミング
バインドレスGPU抽象化レイヤーの作成
Writing a bindless GPU abstraction layer (kevin-gibson.com)
要約
この記事では、Sebastian Aaltonen氏の「No Graphics API」というブログ記事に触発され、既存のAPI上に「Loon GPU」というバインドレスGPU抽象化レイヤーを開発した経験が語られています。このレイヤーは、バッファオブジェクトの廃止、GPUポインタの多用、テクスチャとサンプラーのバインドレス処理などを特徴とし、CPU側とGPU側のコードを大幅に簡素化することを目指しています。
全文翻訳
2025年12月、Sebastian Aaltonen氏は「No Graphics API」と題したブログを公開しました。この記事はGPUハードウェアの進化の素晴らしい歴史を紹介し、現代のハードウェア上で現代のグラフィックスAPIをどのように簡素化できるかについての意見を述べたものです。
多くのグラフィックスプログラマーと同様に、私もSebastian氏のブログ記事を読み、大変楽しみました。その記事に触発され、既存のプラットフォームAPIの上に、彼が説明するAPIにどれだけ近いものが今日実現できるかを見てみることにしました。その結果は「かなり近い」というものでした。その成果が、私がLoon GPUと呼んでいるプロジェクトであり、Githubで公開しています。このライブラリはまだ粗削りで、テストも不十分で、間違いなくバグだらけですが、使用可能であり、他の人々が興味を持つかもしれないので早期に共有したいと思いました。現在、Vulkan 1.3とMetal 4のバックエンドを備えており、ここではAPIデザインがそれらのバックエンドにどのようにマッピングされるかを高レベルで解説します。
要するに、APIは以下のようになります。
バッファオブジェクトはなく、代わりにGPUポインタをあらゆる場所で使用します。
頂点バッファはありません。代わりに頂点プーリングを使用します。これははるかにシンプルです。
テクスチャとサンプラーは、大きなテクスチャヒープオブジェクトへのインデックスとして、バインドレスで扱われます。
明示的なバインドグループはありません。代わりに、シェーダーにデバイスポインタを渡してデータを供給します。
ポインタはファーストクラスです。
APIでは、mallocからGPUメモリを取得し、freeで解放します。マッピング先のバックエンドとは異なり、明示的な「Buffer」オブジェクトの概念はなく、メモリだけがあります。デフォルトでは、CPUに永続的にマッピングされ、CPUからの書き込みに最適化されたメモリを取得しますが、GPUローカルメモリやCPU読み出しに最適化されたマッピングメモリを要求することもできます。
元のブログ記事で説明されているAPIからの変更点は、APIがGPUデバイスポインタを返すことです。これは3つのメモリタイプすべてがマッピングされる唯一のポインタなので、mallocを呼び出すとデバイスポインタが得られ、必要に応じてget_host_pointer()を呼び出してCPU側のポインタに変換できます。
GPUで作業を行うためにdraw()またはdispatch()関数を呼び出す際に、GPUポインタを提供してシェーダーに引数を渡すことができます。Metalではこれは簡単です。APIは任意のGPUポインタを引数テーブルにバインドするサポートを備えています。Vulkanでは、これらをプッシュコンスタント経由でシェーダーに渡します。
内部的には、バッファオブジェクトを作成します。Vulkanでは、すべての割り当ては、割り当てられたメモリ範囲全体にバインドされたVkBufferを取得します。Metalでは、MTLHeapを作成し、同様に範囲全体をカバーするバッファを作成します。両方の基盤となるAPIで、GPUポインタからバッファ+オフセットのペアにマッピングし直す必要がある場合があるため、割り当てられたGPUポインタのソート済みリストを格納し、必要に応じてバイナリサーチを行います。このライブラリを開発している間に、新しいVulkan拡張機能VK_KHR_device_address_commandsが公開され、それが利用可能になれば、このマッピングのほとんどの必要性がなくなるはずです。一般的に、割り当てはかなり重いものとして扱い、ユーザー空間アロケータは必要なすべてのオブジェクトのために割り当てるのではなく、それらを基盤として構築されるべきです。これにより、ルックアップ配列のサイズは小さく保たれ、理想的にはルックアップは必要なときに高速になります。
それほど大きな違いはないように思えるかもしれませんが、APIが単にポインタを提供してくれるだけで、CPU側のコードははるかに自然に感じられます。C言語のオブジェクトのように複雑なデータ構造をより簡単に作成でき、人間工学に基づいたリングバッファを使えば、描画引数の構築は簡単です。CPU側のコードだけでなく、GPU側のコードも大幅に簡素化されるように感じます。バインドレステクスチャと組み合わせることで、パイプラインレイアウト、バインドグループ、またはVulkanのバインディングモデルの複雑さを気にする必要がなくなります。
シェーダー
このラッパーは、シェーダーがどのように見えるかについて意見を持っており、それが生のVulkanやMetalよりも使いやすい理由の一部です。アイデアは、すべてのGPUデータがバインドレスであり、シェーダーはそれらに渡される単一のポインタから入力を取得するというものです。これを機能させるためには、シェーディング言語が必要です。
この実験を開始したとき、Slangを使用することにしました。ポインタのサポートがあり、SPIRVとMetalにコンパイルでき、私のユースケースに適合するように見えました。そして、それはほとんどうまくいきました!しかし、残念ながら、これを使用する上で最も困難な部分となっているいくつかの複雑さがあります。
難しさを理解するために、達成したいことから始めましょう。Vulkanでは、各シェーダーステージに割り当てられた単一のポインタを含むプッシュコンスタント範囲を持たせたいと考えています。Metalでは、各ステージに単一のバッファをバインドしたいと考えています。Loonでは、現在のところ以下のようになっています。
ステージ Vulkan プッシュコンスタント範囲 (バイト) Metal バッファインデックス
Compute [0, 8) 0
Vertex [0, 8) 0
Fragment [8, 16) 1
構文の面では、Slangはシェーダー引数をプッシュコンスタントにマッピングするための非常に良い方法を提供します。以下のような構文でシェーダーステージ(例: 頂点ステージ)を宣言できます。
[shader("vertex")]
VertexStageOutput vertexMain(uniform InputData* args, uint32_t vertexIdx : SV_VertexID)
Slangはuniform InputData*パラメータをプッシュコンスタントに変換します。しかし、残念ながら、これらのプッシュコンスタントのレイアウトを制御する方法はありません。すべてのプッシュコンスタントは0から始まる範囲を取得します。これは、Vulkanがドローコール内のすべてのシェーダーでプッシュコンスタント範囲を共有するため、頂点シェーダーとフラグメントシェーダーに別々の引数を渡すことが事実上不可能になります。これはGithubのイシューで追跡されていますが、一般的なケースでSlangが解決するのは簡単な問題ではありません。回避策として、代わりにグローバルスコープで次のようなことを行います。
struct Args {
VertexInput* vert;
FragmentInput* frag;
};
[[vk::push_constant]] uniform Args args;
しかし、コンピュートシェーダーの場合、エントリポイントパラメータの構文はうまく機能します。
残念ながら、Metalの状況はさらに悪いです。SlangのMetalサポートは実験的であり、VulkanとMetalバックエンド間でシェーダーを共有することを困難または不可能にするいくつかの問題に遭遇しました。将来的にはこれが改善されることを期待しており、修正できると思われるものについてはいくつかのPRを提出しましたが、それまでの間、SlangシェーダーをMetalに手動で翻訳し、実行時にそれらを切り替えています。これはSlangへの批判ではありません。Metalは明らかに主要なターゲットではなく、一般的なバインディングソリューションとしては機能しないような奇妙な方法で使用しようとしています。理想的な解決策は、このバインディングモデル専用のカスタムシェーダーコンパイラまたはトランスパイラかもしれませんが、それは現時点では私の範囲外です。カスタム言語は魅力的なウサギの穴ですが、今はベースライブラリの改善に時間を費やしたいと考えています。
ここで言及する価値のあるもう一つの問題点は、Metalでのスレッドグループサイズの計算です。VulkanやDirectXではスレッドグループサイズはシェーダーアノテーションによって決定されますが、Metalではディスパッチ時に設定されます(パイプライン作成時に最大サイズが決まります)。これはクロスプラットフォームAPIを困難にします。Metal 4は、required_threads_per_threadgroup()アノテーションの追加により、これが修正可能になるという希望を与えてくれましたが、現時点ではCPU側からこの値を読み取る方法がありません。現在、loonはこのアノテーションをコンピュートシェーダーに要求し、アノテーション値を探すために非常にハッキーな解析を行って、その値をAPIに適切に渡せるようにしています。醜いですが、機能します。将来のリリースで、このアノテーション値がパイプラインステートオブジェクトを通じて公開されることを願っています。
Mesaの新しいKosmicKrispドライバーが改善されるにつれて、専用のMetalバックエンドを持つことへの関心が薄れるかもしれません。現在のところ、変換のパフォーマンスは良くありませんが、KKプロジェクトもまだ非常に初期段階であり、ここからさらに改善されるはずです。
描画
これはマイナーな点ですが、標準的なグラフィックスAPIとは異なります。頂点バッファはありません。代わりに、シェーダー内の頂点データへのポインタを使用し、頂点インデックスを使用して必要なものをフェッチします。これにより、APIサーフェスが大幅に削減されます。インデックスバッファは引き続き使用されますが、これもGPUポインタを介して指定されます。
バインドレステクスチャとサンプラー
Metalでは、バインド