ソフトウェアスタック

このサイト (https://fgiesen.wordpress.com/) に最後に記事を投稿してからしばらく経つ。今回は 2011 年時点におけるグラフィックスハードウェアとグラフィックスソフトウェアに関する一般的な事項を説明してみようと思う。あなたが今使っている PC に含まれるグラフィックススタックが持つ機能の説明はウェブを探せば見つけることができるが、そういった文章は「どうやって (how)」や「なぜ (why)」の説明ではないことが多い。このシリーズの狙いはどんなハードウェアに対しても通用する形でそのギャップを埋めることである。この章では Windows で D3D9/10/11 を実行する DX11 クラスのハードウェアに関して話をするが、その理由は私がこの (PC) スタックにたまたま慣れているからであって、以降の章でこの API が重要になるからではない。実際に実行を行う GPU にはネイティブコマンドしか存在しないのだから。

アプリケーション

アプリケーションはあなたが書くコードである。あなたが書いたバグも含まれる。本当だ。API ランタイムとドライバにバグが含まれることもあるが、アプリケーションのバグに関してはあなたに責任がある。さぁとっととバグを直すんだ。

API ランタイム

リソースの作成やステートの設定、あるいはドローコールを行うとき、アプリケーションは API を呼び出す。API ランタイムはアプリケーションの設定した現在状態の管理やパラメータの確認をはじめとしたエラー検出と整合性チェックを行い、さらにシェーダーコードの正当性やリンケージの検証といった処理も行うことがある (少なくとも D3D はシェーダーの検証を API ランタイムで処理する。OpenGL はドライバのレベルで処理する)。その後 API ランタイムは以降の処理を、必要ならいくつかをバッチとしてまとめてから、グラフィックスドライバに──正確に言えば、ユーザーモードのグラフィックスドライバに──受け渡す。

ユーザーモードグラフィックスドライバ (UMD)

CPU 側で起こる "魔法" の多くはユーザーモードドライバ (UMD) で起こる。アプリケーションが API を呼び出したときにクラッシュするなら、クラッシュさせているのはこいつである :) UMD は NVIDIA では nvd3dum.dll と呼ばれ、AMD では atiumd*.dll と呼ばれる。名前からも分かる通り、これはユーザーモードのコードだ: このコードはアプリケーション (および API ランタイム) と同じコンテキストおよび同じアドレス空間で実行され、何の特権も持たない。UMD は低水準 API (device driver interface, DDI) を実装し、この DDI が D3D から呼び出される。DDI の API はアプリケーションが利用する D3D の API と大きくは変わらないが、メモリ管理のような処理が多少明示的になっている。

UMD はシェーダーのコンパイルといった処理を行うモジュールである。D3D では、事前に正当性が検証されたシェーダーのトークンストリームが UMD に渡される──つまりコードは構文的に正しく、D3D の制約 (決められた型だけを使うこと、利用可能な個数のテクスチャ/サンプラーを使うこと、定数バッファの使用量が利用可能な量を超えないこと、など) を満たすという意味で正当であることがチェックされた状態で UMD に渡される。このトークンストリームは HLSL コードをコンパイルしたものであり、通常はたくさんの高水準な最適化 (様々なループ最適化、デッドコード削除、定数伝播、if の事前計算など) が適用されている。こういった比較的高価な最適化がコンパイル時に全て終わっているというのは UMD にとって良いニュースだが、これら以外にもドライバが自分の手で行うべき低水準な最適化 (レジスタアロケーションやループ展開など) が多く存在する。そこで通常 UMD は受け取ったトークンストリームをすぐに中間表現 (intermediate representation, IR) に変換し、それを使ってコンパイルを行う。シェーダーハードウェアは D3D バイトコードに十分近いので、UMD が行うコンパイルによってコードの質が大きく向上するわけではない。ただそれでも、ハードウェアリソースの制限やスケジュールの制約など D3D が関知しない低水準の詳細は多く存在するので、UMD によるコンパイルが自明な処理であるわけではない。

それからもちろん、もしあなたのアプリケーションが有名なゲームなら、NVIDIA や AMD のプログラマーが使われているシェーダーを確認し、自社のハードウェアに最適化された手書きのコードでコンパイル結果を置き換えることもある──当然二つのコードは同じ計算をするべきであり、もしそうでなければスキャンダルだ :) こういったシェーダーの検出と置換も UMD で行われる。どういたしまして。

さらに: 一部の API ステートがシェーダーのコンパイル結果に組み込まれる可能性もある。例えばテクスチャボーダーのような比較的風変わりな (少なくともあまり使われない) 機能は多くの場合テクスチャサンプラーで実装されておらず、シェーダーにコードを追加することでエミュレートされる (そもそもサポートされないこともある)。これは、API ステートごとに同じシェーダーの異なるバージョンが存在する可能性があることを意味する。

UMD が行うコンパイルは新しいシェーダーやリソースを初めて使ったときに遅延が起こる理由でもある。ドライバはリソースの作成やシェーダーのコンパイルの多くを遅延し、必要なときに限って実行する (一部のアプリケーションは信じがたい量の使いもしないゴミを生成する!)。グラフィックスプログラマーはこれが意味するもう一つの事実を知っている──シェーダーやリソースを (メモリを用意するだけではなく) 実際に作成するには、ダミーのドローコールを発行して "温める" 必要がある。これは不格好で面倒な仕様だが、私が 3D ハードウェアを初めて使った 1999 年からこうなっていたので、おそらく今後も変わることはないだろう。慣れてしまうことだ :)

話を戻して、次に進もう。UMD は D3D9 用の "レガシーな" バージョンのシェーダーや固定機能パイプラインといった楽しい処理も行うことになっている──そうだ、こういったものは全て D3D によってしっかりと受け渡される。シェーダープロファイル 3.0 は悪くなかった (というより、かなり良かった) が、2.0 はお粗末だし、たくさんあるシェーダーバージョン 1.x はかなりひどい──ピクセルシェーダー 1.3 を覚えているだろうか? あるいは頂点ライティングが行える固定機能頂点パイプラインなんかは? そういった機能のサポートは現在でも全て D3D に含まれており、現代的なグラフィックスドライバの心臓部に存在する。ただもちろん、いまどきのグラフィックスドライバはそれぞれの機能を新しいシェーダーバージョンを使うものに翻訳することでサポートする (かなり前からこうなっている)。

それからメモリ管理がある。テクスチャ作成などのコマンドを受け取ったとき、UMD はそれ用の空間を用意しなければならない。UMD が実際に行うのは KMD (カーネルモードドライバ) から取得したメモリブロックをサブアロケートする (分割して渡す) という処理である: ページのマップとアンマップにはカーネルモードの特権が必要なので、UMD からは行えない (UMD が扱えるビデオメモリの管理、および逆方向の GPU がアクセスできるシステムメモリの管理も同様に KMD が行う)。

一方で UMD はシステムメモリと (マップされた) ビデオメモリ間のメモリ転送のスケジュールや texture swizzling のような処理は行う。最も重要なこととして、UMD はコマンドバッファに書き込むことができる。コマンドバッファは KMD がアロケートし、UMD に渡す (この UMD に渡されるコマンドバッファは DMA バッファとも呼ばれる)。コマンドバッファに含まれるのは、うむ、コマンドである :) ステートを変更する操作や描画操作は全て UMD によってハードウェアが理解できるコマンドに変換される。プログラマーが直接指示するわけではない操作 (例えばテクスチャやシェーダーのビデオメモリへのアップロード) も多くあるが、これらについても同様に UMD がハードウェア用のコマンドへ変換する。

一般に、ドライバは可能な限り多くの処理を UMD に押し付けようとする。UMD はユーザーモードのコードであり、コストの大きいカーネルモードへの移行が必要とならない。メモリを自由にアロケートできるし、仕事を複数のスレッドに振り分けることもできる──UMD は通常の DLL と変わらないからだ (ただし、この DLL はアプリケーションではなく API ランタイムによってロードされる)。ドライバの開発者にも恩恵がある──UMD がクラッシュするとアプリケーションもクラッシュするが、そのときシステム全体はクラッシュしないで済む。またシステムの実行中に UMD を置き換えることができ、通常のデバッガでデバッグもできる。処理を UMD に移動させると実行が速くなるだけではなく、利便性も向上する。

しかしこの部屋には、これまで言及してこなかった巨大な象がいる。

ユーザーモードドライバは複数ある

先ほど言った通り、UMD はただの DLL に過ぎない。たまたま D3D の祝福を受け KMD とつながる直接のパイプを持ってはいるが、それでも通常の DLL と変わらない。そのため UMD は呼び出した側のアドレス空間で実行される。

しかし、いまどきの OS はマルチタスクである。というよりマルチタスクはずいぶん前から使われている。

さっきから話している「GPU」についてだが、これは共有リソースである。メインディスプレイの描画を行う GPU は (たとえ SLI/Crossfire を使っていたとしても) 一つだけしか存在しない。しかし GPU にアクセスするアプリケーションはいくつも存在し、そのどれもが GPU を使うアプリケーションは自分だけだと思って書かれている。このような状況で全てが自動的に上手く動くことはあり得ない。古き良き時代には、任意の時点で最大でも一つのアプリケーションに 3D 機能を渡すというのが解決法だった。そのアプリケーションがアクティブな間は他の全てのアプリケーションから 3D 機能にアクセスできなかったのである。しかしウィンドウシステムが GPU を使ってレンダリングをするようになると、この解決法は効率良く行えなくなった。そんなわけで、GPU に対するアクセスの調整やタイムスライスのアロケートなどを行う何らかのコンポーネントが必要になる。

スケジューラ

スケジューラはシステムが持つコンポーネントの一つである。「スケジューラ」という言葉は曖昧なことに注意してほしい: 今考えているのはグラフィックスのスケジューラであって、CPU や IO のスケジューラではない。このコンポーネントは名前の通りのことを行う──3D パイプラインへアクセスしようとする異なるアプリケーションの間でタイムスライスを使ってアクセスを調整する。コンテキストスイッチによって GPU 側で (最低でも) いくらかのステートの変更が起こり、加えてリソースをスワップするためにビデオメモリとの入出力が起こる可能性がある。そしてもちろん、任意の時点において 3D パイプラインにコマンドを送れるのは一つのプロセスだけである。

コンソールプログラマーが「PC の 3D API は高水準/無干渉すぎる。パフォーマンスに支障が出てしまう」などと愚痴っているのをよく耳にする。しかしその裏には、PC の 3D API/ドライバがコンソールゲームよりも複雑な問題を抱えているという事情がある──例えば PC では現在のステートを必ず完全な形で管理しなければならない: 腰を下ろしている魔法の絨毯は突然取り払われてしまいかねない! さらに、PC の API/ドライバは壊れたアプリケーションに対するワークアラウンド (応急措置) としてパフォーマンスの問題を後ろから解決しようともする。これはドライバの開発者はもちろん誰も幸せにならない面倒な慣習だが、ビジネス上のメリットが上回るので仕方ない: 人々は現在動くものは今後も必ず動くと (それもスムーズに動くと) 期待するのである。すねて超低速なパスを実行して「だって、間違いなんだよ!」とわめいてみたところで、誰の支持も得られない。

本筋に戻って、パイプラインを進もう。次はカーネルモードだ!

カーネルモードドライバ (KMD)

KMD はハードウェアとの対話を実際に行うコンポーネントである。複数の UMD インスタンスが同時に実行されることはあり得るが、KMD は必ず一つしか存在しない。KMD がクラッシュしたら、ドカーンとなって死ぬ──かつては「ブルースクリーン」になって死んでいたが、実は現在の Windows はクラッシュしたドライバを殺して再読み込みできる (進歩だ!)。ただしこれができるのは KMD が単体でクラッシュしてカーネルメモリが破損していないときであり、もし破損していたら何が起こるかは分からない。

KMD は一つしか存在しないものを全て処理する。例えば GPU メモリは一つしか存在しない。しかし複数のアプリケーションが GPU メモリを取り合うので、誰かが割って入って物理メモリのアロケート (およびマップ) を行わなければならない。同様に、誰かが起動時に GPU を初期化し、ディスプレイモードを設定し、ディスプレイに関する情報を取得し、ハードウェアマウスカーソルを管理し (そうだ、マウスに対するハードウェア処理が存在する。それからマウスは一つで十分だ :)、反応が一定時間なければ GPU をリセットするハードウェアのウォッチドッグタイマーを設定し、割り込みに対応しなければならない。こういったことを KMD は行う。

コンテンツ保護 (DRM) のための処理も KMD には含まれる。つまりビデオプレイヤーと GPU の間に保護されたパスを構築し、汚らしいユーザーモードのコードが尊いピクセルを見る (そしてファイルにダンプする...) ことができないようにする処理である。この部分にも KMD は関係する。

私たちにとって最も重要なこととして、KMD は本当のコマンドバッファ、つまりハードウェアが実際に消費するバッファを管理する。UMD が生成するコマンドバッファは本物のバッファではない──実のところ、それは GPU からアドレスを指定してアクセス可能なメモリの適当なスライスに過ぎない。実際に起こるのは UMD が (本物でない) コマンドバッファを生成してスケジューラに送り、これを受けたスケジューラがそのプロセスの実行中断を待ってから UMD コマンドバッファを KMD に送るという処理である。その後 KMD はそのコマンドバッファの呼び出しをメインのコマンドバッファに書き込むが、このとき GPU コマンドプロセッサがメインメモリからの読み込みを行えるかどうかに応じて、ビデオメモリへの DMA 転送が最初に必要となる場合もある。メインのコマンドバッファは通常 (非常に小さい) リングバッファであり、このバッファにはシステムコマンドと初期化コマンド、そして UMD が用意する中身の詰まった "真の" 3D コマンドバッファの呼び出しだけが書き込まれる。

しかし、今のところ UMD コマンドバッファはまだメインメモリにあるただのバッファに過ぎない。その位置をグラフィックスカードに伝える方法が存在する──通常は GPU 側に読み込みポインタと書き込みポインタが存在し、読み込みポインタはメインコマンドバッファにおける GPU の現在位置を、書き込みポインタは KMD が最後に書き込んだ位置 (より正確には、KMD が最後に書き込んだと GPU に伝えた位置) を表す。それらはメモリマップされたハードウェアレジスタであり、KMD によって定期的に (普通は新しい仕事を送ったときに毎回) 更新される...

バス

...しかし当然、その書き込みはグラフィックスカードに直接向かうわけではない (CPU ダイに統合されていれば別だが!)。その前にバス──いまどきはたいてい PCI Express (PCIe)──を通り抜けなければならない。DMA 転送なども同じルートで行われる。この部分は非常に長いわけではないが、私たちの旅路に含まれる一つのステージである。ここを抜ければついに...

コマンドプロセッサ!

コマンドプロセッサは GPU のフロントエンド──KMD が書き込んだコマンドを実際に読み込むモジュール──である。ここは次回に回すことにしよう。この章は長くなり過ぎている :)

寄り道: OpenGL

OpenGL も今までの説明と大きくは変わらないが、API と UMD レイヤーの区別が曖昧になっている。それから D3D と異なり、OpenGL では API ランタイムが (GLSL) シェーダーのコンパイルを全く行わず、シェーダーは全てドライバによってコンパイルされる。この設計の不幸な副作用が、3D ハードウェアベンダーと同じ数だけの GLSL フロントエンドが存在してしまうことだ。各フロントエンドはどれも基本的に同じ仕様を実装するのだが、バグや詳細がそれぞれ異なってしまう。これは楽しくない。さらに、この設計はドライバがシェーダーを扱うときに全ての最適化を必ず行わなければならないことを意味する──最適化の中には高価なものも含まれる。これに比べると、D3D バイトコードフォーマットは非常に賢い解決法と言える──コンパイラが一つだけなのでベンダーごとに少しずつ異なる非互換な方言が生まれることはなく、データフロー解析といった通常は行わないコストが高い処理も行える。

省略/単純化

以上は概略に過ぎない: 触れなかった細かい点はたくさんある。例えばスケジューラは一つだけではなく、複数の実装が存在する (ドライバから選択できる)。また CPU と GPU の同期という大きな問題もあるが、全く説明していない。何か重要なことも忘れているかもしれない。もし気付いたら、直すので教えてほしい! 今のところはこれで終わりだ。また次の章で会おう。

広告