ピクセルプロセッシング ── ジョインフェーズ
おかえり! この章ではピクセルプロセッシングの後半部分である「ジョインフェーズ」を扱う。前章では少数の入力ストリームを受け取ってそれを大量の独立したシェーダーユニット用タスクに変換する部分について話をした。この後は起動した大量の独立した計算を (正しく順序付いた) メモリ操作のストリームとして一つにまとめなければならない。ラスタライズとアーリー Z を説明したときと同じように、行うべき処理を一般的なレベルで最初に説明してから、それがハードウェアにどう対応付くかを説明する。
ピクセルを再び合成する: ブレンドとレイト Z/ステンシル
パイプラインの底にはレイト Z とステンシル、そしてブレンドがある (D3D はこの部分を「出力マージャー」と呼ぶ)。これらの操作はどれも計算量的には比較的単純であり、レンダーターゲットあるいはデプスバッファを更新する。ここで「更新」とは read-modify-write という形態の操作を意味している。これらの操作はパイプラインをここまで通過した全てのクアッドに対して行われるので、非常に帯域を食う操作でもある。最後に、これらの操作では順序が重要となる (ブレンドと Z プロセッシングは API が指定する順序で行う必要がある) ので、シェーディングが終わったクアッドはまずソートされなければならない。
Z テストの処理は前に説明した。またブレンドは全く想像通りの動作をするブロックである: レンダーターゲットごとに一度の乗算と一度の積和を行う (場合によっては減算が最初に付く)。このブロックは意図的に単純に保たれている。ブレンドを行うのはシェーダーと異なる個別のユニットであり、専用の ALU を必要とするが、これは可能な限り小さいことが望ましい: チップの面積 (および電力) はシェーダーユニット内の ALU に費やすべきである。シェーダーユニットを増やせば GPU で実行される任意のコードが恩恵を受けるので、ピクセルパイプラインの終端でだけ使われる固定関数ユニットよりもシェーダーユニットが優先される。またこのブロックは短くて予測可能なレイテンシを持つことも要求される: このブロックが正しく動作するにはデータを順番通りに処理しなければならないためだ。この事実はレイテンシとスループットのトレードオフに関して選択肢を狭める。クアッドは重ならない限り並列に処理できるものの、例えば小さい三角形を大量に描画しているときは、スクリーン内の全ての場所に対して複数のクアッドが送られて来ることになる。そういった場合にクアッドが送られて来るのと同じ速度で書き込みを行わないと、大規模並列なピクセルプロセッシングが全て無駄になってしまう。
ROP
パイプラインのこの部分を担当するハードウェアユニットが ROP である (GPU の中には複数の ROP が存在する)。この頭字語は「Render OutPut unit」「Rester Operations Pipeline」「Raster Operations Processor」のいずれかの省略形とされる (聞く人によって違う答えが返ってくるだろう)。この名前のユニットが登場したのはずいぶん昔の話だ──完全に 2D のハードウェアアクセラレーションが使われていた時代で、そのころハードウェアの主な用途は BitBlt の実行だった。古典的な 2D ROP の設計では入力が三つ──(目標の) フレームバッファにおける現在のピクセル値、ソースデータ、マスク入力が──存在し、ROP はこの三つの値を入力とする関数を計算して結果をフレームバッファに書き戻す処理を行った。これはトゥルーカラーディスプレイより前の時代であることに注意してほしい: 画像データは通常ビットプレーンフォーマットであり、ROP が実行する関数は二項の論理関数だった。その後ビットプレーンが消え (同じピクセルに対するビットを合わせて管理する "分厚い" 表現が好まれ) てトゥルーカラーが標準となり、オン-オフのマスクはアルファチャンネルで置き換えられ、ビットごとの演算はブレンドとなったが、名前はそのまま残った。このような経緯があり、2011 年現在では元々あった機能の面影は OpenGL の「logic op」という用語にしかほぼ残っていないものの、このユニットは ROP と呼ばれている。
ではレイト Z とステンシル、そしてブレンドのためにハードウェアで行うべき処理は何だろう? 話はそれほど難しくない:
- 改変前のレンダーターゲットまたはデプスバッファの内容をメモリから読む──メモリアクセスであり、レイテンシは長い。レンダーターゲットまたはデプスバッファの圧縮 (後述) が絡むかもしれない!
- 送られて来るクアッドを正しい順序 (API が指定する順序) にソートする。クアッドが正しい順序でやってこないときにストールを防ぐには、ここにバッファが必要となる (ループやブランチ、破棄、様々なテクスチャフェッチのレイテンシを考えてほしい)。なお、ここではプリミティブ ID についてのソートだけが必要になる──同じプリミティブからの二つのクアッドは互いに重ならず、重ならないならソートは必要ない!
- 実際のブレンド/レイト Z/ステンシル操作を行う。これは数学的な演算である──深くパイプライン化されたユニットを使ったとしても、数十サイクル程度で完了する。
- 結果をメモリに書き戻し、そのとき圧縮などを行う──また長いレイテンシを持つ操作だが、ここでは結果を待っているわけではないので、ROP の側ではそれほど問題にならない。
つまり、レイト Z/ステンシル/ブレンドを行うユニットを作って、そこに圧縮ロジックを入れて、このユニットの片方をメモリと繋げて、もう一方をシェーディングが終わったクアッドを入れるバッファに繋げば終わり...ということか?
まぁ、理論上は、そうだ。
しかし、どうにかして長いレイテンシを隠さなければならない。また上述の処理は一つの例外なく全てのピクセル (正確にはクアッド) に対して起こるので、メモリ帯域も気にかける必要がある...メモリ帯域? 前にメモリ帯域について話したような? さぁ、二章で隠したウサギを帽子から取り出すので注目してほしい (隠したのはもう一週間以上前だ──まだ生きていればいいのだが...)。
帰ってきたメモリ帯域: DRAM ページ
二章では DRAM が二次元レイアウトを持っていて、アクティブな行を変えると時間がかかるために同じ行で作業を行った方が高速であることを説明した──理想的な帯域を得るには、アクセスの間で行を変えない方が望ましい。さて、ここで重要なのが、単一の行がそれなりに大きいという事実である。いまどきの DRAM チップは一つで数ギガビットになる。チップが必ず正方形であるわけではない (実際には 2:1 のアスペクト比が好まれるように思える) ものの、行と列がどれくらいの大きさになるかは概算できる: 512 メガビット (=64 メガバイト) の DRAM であれば、512 メガ = 16384×32768 より一つの行は 32 キロビット (つまり 4 キロバイト) 程度と見積もれる (2 キロバイトあるいは 8 キロバイトかもしれないが、何が言いたいかは分かるだろう)。これはメモリのトランザクションを行うには不便なサイズである。
そこで折衷案が取られる: ページだ。DRAM ページは行をもっと扱いやすいサイズ (現在は 256 ビットまたは 512 ビットであることが多い) で切った領域であり、一度のバーストでは通常一つのページが転送される。ここではページのサイズを 512 ビット (64 バイト) として話をしよう。ピクセルごとに 32 ビットを使うとすれば──デプスバッファにおける標準的な値であり、レンダーターゲットでもこの値が使われることが多い。ただしレンダリングのワークロードは間違いなく 64 ビット/ピクセルのフォーマットに移行している──単一のページには 16 ピクセル分のデータを載せることができる。おっと、これは面白い──ピクセルのシェーディングも 16 個から 64 個のグループで行うのだった! (NVIDIA は小さい方の値に近く、AMD は大きい方の値に近い) 実は、ラスタライズとアーリー Z の説明で使った 8x8 というタイルは AMD から取った値である。NVIDIA が粗い走査を 4x4 のタイルに対して行っていたとしても私は驚かないが、簡単なウェブ検索では確かなことは分からなかった。いずれにせよ、話が込み入ってきた。つまり、ピクセルの走査を DRAM ページのコヒーレンシーが向上するような順序で行うということか? そういうことだ。これはレンダーターゲットの内部レイアウトにも影響を及ぼすことに注意してほしい: 単一の DRAM ページが実際の処理で使いやすい形状になるようにピクセルが格納されることが望ましい。シェーディングにおいては、一つの DRAM ページ表すピクセルが 16x1 であるよりも 4x4 や 8x2 である方がずっと使いやすい (思い出そう──クアッドだ)。これは通常レンダーターゲットがメモリ上に線形のレイアウトで並べられない理由である。
これはピクセルのシェーディングをグループごとに行うべきもう一つの理由を与え、さらに二つのレベルを使った走査を行うべきもう一つの理由も与える。もっと利用できないだろうか? できるとも: メモリレイテンシの問題はまだ解決されていない。いつもの注意事項: この部分について私は実際の GPU の動作に関する詳しい情報を持っていない。そのためここで私が説明するのは推測であり、事実であるとは限らない。話を進めると、タイルをラスタライズした直後には、そのタイルがピクセルを一つでも生成するかどうかが分かる。よってその時点でそのタイルに含まれるクアッドを処理する ROP を選択し、対応するフレームバッファのデータをバッファにフェッチするコマンドをキューに積むことができる。シェーディングが終了したクアッドがシェーダーユニットから返ってくる頃にはそのデータの準備ができているはずであり、遅延なくブレンドを始めることができる (もちろん、ブレンドが有効になっていない場合や恒等ブレンドの場合はブレンドが全て無視される)。Z データでも同様だ──アーリー Z をピクセルシェーダーの前に実行するなら、ROP のアロケートとデプス/ステンシルで使うデータのフェッチを早く、おそらくはタイルが粗い Z テストを通過した時点で、行う必要があるかもしれない。レイト Z を実行するときは、フレームバッファのピクセルを取得するのと同じタイミングでデプスバッファのデータもプリフェッチできる (Z テストが無効ならもちろん飛ばせる)。
このように操作を早く行えば、レイテンシによるストールを避けられる (非常に高速なピクセルシェーダーでは避けられない可能性があるが、そういったシェーダーはいずれにせよメモリバウンドになることが多い)。また複数のレンダーターゲットに出力するピクセルシェーダーという問題もあるが、これはこの機能がどう実装されるかによって解決法が異なる。シェーダーを複数回実行することもできる (効率は落ちるが、出力バッファのサイズが固定されている状況では最も簡単に行える) し、同じ ROP を使って全てのレンダーターゲットへの処理を実行することもできる (ただしレンダーターゲットは 8 つまで、フォーマットは 128 ビット/ピクセルまでとなる──今考えているバッファ空間からすると非常に大きい) し、レンダーターゲットごとに ROP をアロケートすることもできる。
それからもちろん、こういったバッファがせっかく ROP に存在しているのだから、これを小さなキャッシュとして扱う (つまり少しの間とっておく) のも選択肢に入る。これは小さい三角形を大量にレンダリングするときに役立つだろう──それらの三角形が空間的な局所性を持っていればの話だが。ここでもまた、私は GPU が実際にこれを行っているかどうか確かなことを知らない。しかし行っていてもおかしくないことに思える (ただ、このバッファはバッチごとに一度程度の間隔でフラッシュが必要になるだろう。完全なライトバックが引き起こす同期/一貫性の問題を避けるためだ)。
オーケー、メモリに関することはこれで説明できた。計算に関することも以前に触れたから、次は圧縮だ!
デプスバッファとカラーバッファの圧縮
七章で Z テストについて話をしたときに圧縮の基本的な動作も説明した。実のところ、デプスバッファの圧縮については付け加えることが特にない。ただ、そこで触れた帯域の問題はカラーバッファにも存在する。通常のレンダリングでは (ピクセルシェーダーがメモリ帯域よりも高速にピクセルを出力する場合を除けば) デプスバッファほど問題にはならないが、MSAA を使うと突然ピクセルごとに二つから八つのサンプルを格納する必要が生じるので深刻な問題になる。そこで Z テストの場合と同様に、何らかのロスレスな圧縮スキームを使って帯域を節約するのが望ましい。ただ Z テストと異なり、タイルごとに平面方程式を使う方法はテクスチャのピクセルデータに対して上手く動作しない。
しかし問題はない。なぜなら実は、MSAA を使ったときのピクセルデータはデプスデータより簡単に最適化できるからだ: ピクセルシェーダーがサンプルごとではなくピクセルごとに実行されることを思い出してほしい──sample-frequency shading を使うときは別だが、これは D3D11 で追加された機能だし、(今のところ?) あまり使われていない。よって一つのプリミティブによって完全に被覆される任意のピクセルでは、2~8 個のサンプルに対して計算される値は通常同じになる。これがカラーバッファ圧縮の背後にあるアイデアである: 圧縮ブロック (一ピクセルまたは一クアッド、もしくはもっと大きな領域) ごとに、そこに含まれる全てのピクセル内のサンプルの色が全て同じであるかどうかを示すフラグビットを書き込んでおく。もしそのフラグが立っているなら、色を書き込むのはピクセルごとに一度だけで済む。これは書き戻しのときに非常に簡単に検出できるが、ここでもデプス圧縮と同様に小さなオンチップの SRAM へタグビットを保存する必要がある。またピクセルを横切る辺があれば、そこでは完全な帯域が必要になる。しかし三角形が小さすぎない限りは、少なくともフレームの一部において帯域を大きく節約できる (全ての三角形が小さいということは基本的にまずない)。またカラーバッファでも、同じ機構を使ってクリアを高速化できる。
クリアと圧縮の話題に関して、もう一つ言及すべきことがある: 一部の GPU は「階層的 Z テスト」に似た機構をカラーバッファ用に持っており、最近クリアされたピクセル領域のブロックに対してこれが利用される。この機構があるとタイル (またはそれより大きなブロック) 全体につき一つの色をメモリに格納するだけで済むので、一部のバッファに対する非常に高速なカラークリアが可能になる (多少のタグビットは必要だが!)。しかしクリアした色と異なる色が一つでもタイル (あるいはブロック) 内のピクセルに書き込まれると、「ここはクリアされている」というフラグを...まぁ、クリアしなければならない。ただそれでも、クリアするときと最初にメモリから読み込むときに帯域を大きく削減できる。
私たちが見てきた最初のレンダリングデータパスはこれで終わりである: この (最も典型的な) パスには頂点シェーダーとピクセルシェーダーがあるだけだ。次はジオメトリシェーダーおよびジオメトリシェーダーとパイプラインのやり取りを説明する。ただこの章を終える前に、この章に入れておきたい短いボーナストピックがある。
余談: 完全にプログラム可能なブレンドが存在しないのはなぜ?
レンダリングコードを書いたことがある人なら誰でも、一度は不思議に思ったことがあるだろう──通常のブレンドパイプラインはときに非常に複雑で分かりにくくなる。であれば、どうして完全にプログラム可能なブレンドが存在しないのだろう? シェーダーは完全にプログラム可能なのに! さて、この領域に踏み込むのに必要なフレームワークはもう手にしている。私が見たことのある提案が二つある──順に見ていこう:
- ピクセルシェーダーでブレンドを行う──つまりピクセルシェーダーでフレームバッファを読み、ブレンド方程式を計算し、新しい値を出力する。
- プログラム可能なブレンドユニット──"ブレンドシェーダー"──を作る。必要ならシェーダー命令の一部が利用できるようにして、ピクセルシェーダーの後に個別のステージとして実行する。
一つ目の選択肢: ピクセルシェーダーでブレンドを行う
これは簡単な話に思える: 結局私たちは読み込みがしたくて、シェーダーにはテクスチャサンプラーがあるのだから、現在のレンダーターゲットからの読み込みを許すだけで問題は解決するのでは? しかし制約のない読み込みは非常に悪いアイデアであることが判明している。シェーディングされる全てのピクセルが他の全てのピクセルに影響を及ぼす (可能性が生まれる) ためだ。もし左隣のクアッドに含まれるピクセルを参照したら? そのピクセルに対するシェーディングは参照を行った時点でまだ実行中かもしれない。あるいは現在のクアッドの半分と別の (現在アクティブな) クアッドの半分をサンプルするかもしれない──どうするべきだろうか? この状況では正しい結果が何かがはっきりせず、そもそもこういった計算を信頼できる形で行うには全てのクアッドを逐次的に処理するしかない。ダメだ、これはあまりにひどい。ピクセルシェーダーでフレームバッファから制約のない読み込みを行うというアイデアは諦めよう。では、アクティブなレンダーターゲットの現在位置からサンプルする特別な読み込み命令を追加したら? これはずっとましに思える──現在処理しているクアッドの位置に対する書き込みだけを気にすれば済むので、問題は格段に扱いやすくなった。
しかし、こうしたとしても順序の制約が生まれてしまう: ラスタライザが生成する全てのクアッドと、現在ピクセルシェーダーを実行中のクアッドを比べなければならない。ラスタライザによって生成されたクアッドが書き込もうとするサンプルが、現在実行中のピクセルシェーダーがちょうど書き込もうとしているサンプルだったとしたら、そのピクセルシェーダーが終了するまで二つ目の新しいクアッドはディスパッチできない。これ自体はそれほど悪いように見えないが、これを管理するにはどうするべきだろうか? 「このサンプルは現在シェーディングされている」というビットフラグを使うのも選択肢の一つだ。このとき必要になるビット数を調べてみよう: 1920x1080 で 8x MSAA だと、このフラグ用に 2 メガバイト (ビットではなくバイト) のメモリが必要になる。さらにこのメモリはグローバルで共有されるから、この部分が新しいクアッドを発行する速さを決定することになる (クアッドを発行するときにビットを設定しなければならない)。さらに悪いことに、このメモリは省略できない: 階層的 Z テストなどではタグビットがただのヒントであり、利用できなかったとしてもレンダリングを (遅くはなるが) 進められる。しかしここでは本当に全てのサンプルを追跡しない限り、結果の正しさを保証できない! ではピクセル (あるいはクアッド) ごとに「ビジー」の状態を追跡して、あるサンプルのピクセルへの書き込みがその単位に含まれる他のサンプルからの書き込みをブロックする方法はどうだろう? これでも動作はするが、MSAA の性能が大きく落ちてしまう。もしサンプルごとに状態を管理するなら、隣接する重ならない三角形を並列にシェーディングできる。ここに問題はない。しかし状態をピクセルごとに (あるいはもっと大きな単位で) 管理すると、辺を共有して隣接するクアッドを全て逐次的に処理することになる。このとき例えばパーティクルシステムのようなオーバードローが多く起きる状況でフィルレートはどうなるだろうか? 上述のパイプラインでは、そういったレンダリングの速度は (大まかに言って) ROP が送られて来るピクセルをバッファに格納する速度と同程度になる。しかし書き込みの衝突を避ける必要があると、オーバーラップするパーティクルのシェーディングを本当に順序通りに行う必要が生じる。これはレイテンシを犠牲にしてスループットを取るように設計されているシェーダーユニットにとって良いニュースではない。ちっとも良くない。
というわけで、ピクセルを追跡するアイデアはどうしても問題になるようだ。ではシェーダーを順序通りに実行することを強制したら? つまり、何でもパイプライン化して、全てのシェーダーをロックステップで実行したらどうだろうか。そうすればピクセルはパイプラインに入れたのと同じ順序でシェーディングを追えるので、追跡する必要がない! しかしそうしたときの問題はバッチに含まれるシェーダーが全て同じ時間で実行されることを保証しなければならないことで、これは残念な結果を伴う。あらゆるテクスチャサンプルで最悪の遅延が発生し、全ての分岐で両方のブランチを実行しなければならず (いずれ誰かが then/else ブランチを実行する可能性があり、全員が同じ時間で実行を進めなければならない!)、どんなループも同じ回数だけ反復する必要があり、シェーディングを途中でやめたり破棄することもできない...ダメだ。こうしても上手く行きそうにない。
さて、現実を受け止めよう: 今まで説明してきたアーキテクチャにおけるピクセルシェーダーでブレンドを行うと、非常に厄介な問題が発生する。では二番目のアプローチはどうだろうか?
二つ目の選択肢: "ブレンドシェーダー"
最初に言っておこう: このアプローチは実現できなくはない。でも...
このアプローチには異なる種類の問題がある。まず、完全な ALU と命令デコーダ/シーケンサが ROP に必要になる。これは設計、面積、消費電力のいずれに関しても小さな変更ではない。次に、この章で最初に触れたように、「広くなろう」という考え方はブレンドと噛み合わない。ブレンドは同じピクセルに書き込もうとするクアッドをいくつも受け取る可能性があるので、正しい順序でそれらを処理する必要があり、低いレイテンシが要求される。これは通常のユニファイドシェーダーユニットとは大きく異なる設計上の観点である──よってユニファイドシェーダーユニットをここで使うことはできない (これはブレンドシェーダーからのテクスチャサンプルやメモリアクセスも大きな NO であることを意味するが、この事実に驚く読者はいないだろう)。第三に、純粋な逐次的実行は行えない──スループットが低すぎる。そこでパイプライン化が必要になるが、パイプライン化を行うときはパイプラインの長さを知らなければならない! 通常のブレンドユニットは固定長だから簡単だが、おそらくブレンドシェーダーではそうはならない。実は設計上の制約により、ブレンドシェーダーが実現する可能性は低い──あるとすれば、命令数に (おそらく比較的低い) 上限の付いたレジスタコンバイナのようなものだろう。その命令数の上限はパイプラインの長さによって決定される。
要するに、ブレンドでは処理を逐次的に実行しなければならないという制約により設計が比較的低水準になるので、ブレンドを担当するユニットは私たちが大好きな完全にプログラム可能なシェーダーユニットから遠く離れたものになる。追加のブレンドモードを持った素敵なブレンドユニットなら確実に可能である。それよりオープンなレジスタコンバイナ風の設計は可能かもしれないが、そんな設計は API に取り組む人にもハードウェアに取り組む人にも好まれないだろう (API 側からは固定関数ブロックであるために嫌われ、ハードウェア側からは大きな ALU と制御のロジックが小さく保ちたい場所に必要となるために嫌われる)。分岐やブランチを持った完全にプログラム可能なブレンド──これは実現しそうにない。ここまで読んだなら、せっかくだから「ピクセルシェーダーでブレンド」のシナリオを正しく動作させるにはどうすればよいかを考えてみてもいいかもしれない。
...そしてこの章はこれで終わりだ! 次章で会おう。