2019年1月12日土曜日

Direct3D12について雑記 その2 GPUへのコマンド発行

・GPUへのコマンド発行

DX11とは違い、DX12ではGPUへコマンドを発行するために使用するインターフェイスは増えている。

DX11ではID3D11DeviceContextのみでコマンドの発行を行っていたが、DX12ではそれを以下のものへ分割したものになっている。

  • ID3D12CommandQueue : 実際にコマンドをGPUに発行するもの
  • ID3D12CommandAllocator : コマンドの記録領域
  • ID3D12GraphicsCommandList : コマンドを記述していくもの

この中でID3D12GraphicsCommandListがID3D11DeviceContextと似たインターフェイスになっているので先に紹介する。


・コマンドを記述するためのもの ID3D12GraphicsCommandList


使い方はID3D11DeviceContextと変わりない。
シェーダの設定がDX12で大きく変わったので、ID3D11DeviceContextのVSSetXXX()やPSSetXXX()がなくなって、ID3D12PipelineState、ID3D12RootSignatureの設定に置き換わっている。

その一方でレンダーターゲットや深度バッファのクリアー関数、頂点バッファのバインドは同じように使える。
(ただしDX12でビューの仕様が変わっているので、そこは異なる)

変わっていない部分は以下のものになる。
  • Input-Assembler Stage (IA) : 頂点バッファ、インディクスバッファ、Primitive Topologyの設定を行う。
  • Stream-Output Stage (SO) : ジオメトリシェーダで出力するバッファの設定を行う。
  • Rasterizer Stage (RS) : ビューポート/シザー領域の設定を行う。
  • Query : GPUのタイムスタンプやSOステージの出力結果の情報、描画で実際に書き込まれたピクセル数などを問い合わせることができる。ただし、ID3D12QueryHeapで結果を受け取る記録領域をこちらで用意しないといけない。
  • リソースのクリアー、コピー : そのまま。コピーに関しては専用のID3D12CommandQueue/ID3D12GraphicsCommandListを作ることができる。詳しくは下の方で。
  • Draw/Dispatchコール : バリエーションは減っている。ただし、Indirect Draw/DispatchはExecuteIndirect関数に置き換わっていて、やり方は変わっている。

変わった部分は以下のものになる。

  • シェーダ/パラメータの設定 : ID3D12RootSignature/ID3D12PipelineStateを使って設定するようになっている。
  • Output-Merger Stage (MO) : 上と同じ。Blend StateやDepth Stencil StateなどはID3D12PipelineStateで設定することになっている。レンダーターゲットの指定は変わっていないが、ID3D12PipelineStateでそのフォーマットを決める必要がある。

    シェーダ/リソースの設定をID3D12RootSignature/ID3D12PipelineStateで行うようなった影響を受けている。

    これら二つでシェーダの設定を行うようになったことで、Graphics Pipelineの切り替えコストが減っている。

    ただ、その分、同じシェーダを使うが不透明/半透明の二通りで描画したい、加算合成で描画したいなど、ちょっとした状態の違いを持つものでも、すべて異なるID3D12PipelineStateを作る必要があるので、管理はめんどうになっている。

    上のようなケースだと余分に容量を使用したり、コンパイルの時間がかかってしまう問題があるが、ID3D12PipelineLibraryを使うことで軽減は出来る。ただ、あくまでコンパイルとデータ容量の削減をしてくれるだけで、そのバリエーションの分ID3D12PipelineStateを作り、その切り替えはこちらでする必要がある。

    新しく増えたものは以下のものになる。

    • Reset/Close : コマンドを記述する際は毎回Resetを行い、明示的にCloseする必要がある。
    • リソースのバリアー指定 : DX12では各Draw/Dispatch時のリソースの使われ方を指定する必要がある。GPU内のキャッシュの扱いに影響する。DX11ではドライバーが行っていた。

    ・ID3D12GraphicsCommandListの種類。

    ID3D12GraphicsCommandListには用途に合わせて以下の種類がある。
    • D3D12_COMMAND_LIST_TYPE_DIRECT : 全コマンド記録可能
    • D3D12_COMMAND_LIST_TYPE_COMPUTE : コンピュートシェーダとコピー関連のコマンドを記録できる。
    • D3D12_COMMAND_LIST_TYPE_COPY : コピー関連のコマンドしか記録できない
    • D3D12_COMMAND_LIST_TYPE_BUNDLE : 毎フレーム同じ内容になるコマンドをまとめて使いまわせるようにするときに使う。c++でいうプリコンパイル済みヘッダーみたいなもの。使えるコマンドに制約がある。ID3D12GraphicsCommandList::ExecuteBundle()で記録したコマンドを他のID3D12GraphicsCommandListに書き込むことができる。
    • D3D12_COMMAND_LIST_TYPE_VIDEO_DECODE/D3D12_COMMAND_LIST_TYPE_VIDEO_PROCESS : 動画関係のコマンドを作るときに使われるみたい。

    上三つが基本的な種類になる。
    D3D12_COMMAND_LIST_TYPE_DIRECTを使っていれば問題はない。
    ただ、GPUにはグラフィックスパイプライン用のキューとコンピュートシェーダ用のキュー、コピー用のキューを持ち、GPU(によって)はそれぞれ独立して実行できる。

    このキューの種類はID3D12CommandQueueの作成時に指定でき、効率的なアプリを作るなら、意識してコマンドを作る必要が出てくる。

    以上がID3D12GraphicsCommandListの簡単な紹介である。

    基本的な使い方の流れは以下のものになる。
    1. ID3D12GraphicsCommandList::Resetする
    2. GPUにやらせたいことを記録していく。
    3. ID3D12GraphicsCommandList::Closeで記述を終える
    4. ID3D12CommandQueueを使ってGPUにコマンドを伝える。
    ID3D12GraphicsCommandListはあくまでGPUへのコマンドの記録のみを行うものである。

    記録のみで実行はしないので、複数のスレッドで同時にGPUへのコマンドを作成することができるようなった。(DX11でもDeffered Contextを使えばできるが、暗黙のルールが多くDX12でそれらが明示的になった感ある。)

    ちなみにDX11のID3D11DeviceContextにはImmediate ContextとDeffered Contextの二種類あったが、ID3D12GraphicsCommandListはDeffered Contextと同じものだ。


    ・コマンドの記録領域 ID3D12CommandAllocator

    ID3D12GraphicsCommandListを作るときとリセットするときは実行したコマンドの記録領域となるID3D12CommandAllocatorが必要になる。

    一つのID3D12CommandAllocatorに対して複数のID3D12GraphicsCommandListが利用できる。
    が、スレッドフリーではないので各スレッドごとに一つのID3D12CommandAllocatorを作る形になる。

    ID3D12CommandAllocatorはResetしない限りメモリを延々と確保し続ける。気が付いたらテクスチャよりメモリを取っていたことも出かねないので、必要な分のコマンドを記録したらResetしないといけない。

    ただし、ResetはGPUがID3D12CommandAllocatorが確保したメモリを使っていないときにしかできない。つまり、ID3D12CommandAllocatorにコマンドを記録しているID3D12GraphicsCommandListの内容をGPUが実行しているときはID3D12CommandAllocatorのリセットはできないので注意が必要だ。

    また、ID3D12CommandAllocatorにもID3D12GraphicsCommandListと同じ数の種類がある。
    同じ種類のID3D12CommandAllocatorは同じ種類のID3D12GraphicsCommandListしか使用できない。

    ・GPUへコマンドを発行する ID3D12CommandQueue


    実際にGPUへコマンドを発行するにはID3D12CommandQueueを使用する。

    ID3D12CommandQueueは基本的にID3D12GraphicsCommandListを受け取り、GPUへ発行する役割をもつ。

    あとはタイムスタンプの取得、Reserved Resourceと呼ばれる仮想GPUメモリへのコピー/更新機能がある。

    ID3D12CommandQueueにもID3D12GraphicsCommandListと同じく種類があり、以下の3種類だ。
    • D3D12_COMMAND_LIST_TYPE_DIRECT : 全てのコマンドが使用できる。グラフィックス関係に使われるのがほとんど。
    • D3D12_COMMAND_LIST_TYPE_COMPUTE : コンピュートシェーダとコピー関連のコマンドを実行できる。使っているデバイスによってはグラフィックスパイプラインと同時に実行できるものがあり、非同期コンピューティングと呼ばれている。
    • D3D12_COMMAND_LIST_TYPE_COPY : コピーコマンドを実行できる。GPUとCPU間を高速でデータを転送するPCI Expressに最適化されている。
    使用しているデバイスによっては種類が異なるID3D12CommandQueueを同時に実行できる場合がある。この特性を生かさないとDX11より性能を引き出すことはできない。

    特にコンピュートシェーダとグラフィックスパイプラインを並列で動作させることを非同期コンピューティングと呼び、最適化する上で重要な意味を持つ。

    ただ、AMDのGPUが最初に対応した歴史から(GCNアーキテクチャから)、AMD以外のGPUでは思ったより性能が出ないこともある。NVIDIAもPascal世代(GTX10XX)以降から対応しているが、AMDのものよりかは効果は出ない。

    (NVIDIAはVR向けのSingle Pass Stereo機能を先に実装したりと、進化の方向が異なっているので、AMDと比べてどちらが優秀とは言えない。)

    非同期コンピューティングは使用しているデバイスの影響を受けやすいものであることは覚えておいた方がいい。



    以上でGPUにコマンドを発行するために使用するものについてみてきた。
    がこのままだと、GPUがいつ処理を終えたのかがわからない。

    なので、次はID3D12Fenceについて見ていく。

    ・同期処理


    GPUに発行した処理が終わったことを確認するにはID3D12Fenceを使用する。

    上記のインターフェイスを使うことでGPUにコマンドを発行できる。
    しかし、このままだとGPUがその発行したものをいつ処理し終えたかは確認することはできない。

    このままだと、どのタイミングで前フレームの描画が完了したかわからず、でたらめのタイミングでID3D12CommandAllocatorをリセットせざる終えない。

    いわゆる同期待ちができない状態である。

    そのためまだGPUが使っていないのにコマンドが消えてしまったというNULLポインターエラーっぽいバグが発生してしまう。(しかも、どのタイミングで起きるかわからないおまけ付きで)

    このような場合のためにID3D12Fenceが用意されている。

    同期待ちをしたいコマンドを記録したID3D12CommandAllocatorをID3D12CommandQueueで発行した後にID3D12CommandQueue::Signal関数にID3D12Fenceを渡してあげることで、同期待ちのタイミングを作ることができる。

    同期待ちにはWindowsAPIのWaitForSingleObject関数を利用する。

    そのためID3D12FenceはWinAPIのCreateEvent関数で作成したHANDLEと一緒に作成する必要がある。

    //ソースコード
    
    //ID3D12Fenceの作成
    ID3D12Fence fence;
    auto hr = device->CreateFence(initialValue, fenceFlag, IID_PPV_ARGS(&fence));
    HANDLE handle = CreateHandle(nullptr, FALSE, FALSE, nullptr);
    
    // 実行したいコマンドを作成
    
    ...
    commandQueue->ExecuteCommandLists(...);
    
    //同期待ちしたいタイミングを指定
    commandQueue->Signal(fence, nextValue);
    
    
    
    //同期待ち
    
    if (fence->GetCompletedValue() < nextValue) {
      auto hr = fence->SetEventOnCompletion(nextValue, handle);
      if (SUCCESSED(hr)){
        WaitForSingleObject(handle, waitMilliseconds/*-1でタイムアウトなし*/):
      } else {
        //エラー発生
      }
    }
    
    commandQueue->Signal()で同期ポイントを作る。

    GPUがこのSignal部分までコマンドを実行すると、指定したfenceの内部カウンターをnextValueに更新する。

    fenceの内部カウンターはID3D12Fence::GetCompletedValue()で取得できる。

    後は内部カウンターの値がnextValueになっていないか確認したら、ID3D12Fence::SetEventOnCompletion()とWaitForSingleObject()を使って同期待ちする。

    余談だが、commandQueue->Signal()はGPU駆動の同期待ちである。
    CPU上からID3D12Fenceを利用して同期待ちしたいときはID3D12Fence::Wait()を使うといい。

    ・ID3D12Fenceの同期待ちはカウンターを使ったもの


    ID3D12Fenceを使った同期待ちはUINT64型のカウンター一つづつ進めていくものになる。
    そのため、一回同期ポイントを作るたびに1つカウンターを進める処理になるため、桁あふれという限界がいずれ来る。

    なので、ある周期ごとにリセットした方がいいのではないかと考えるだろう。

    しかし、UINT64型なら60FPSで毎フレーム当たり1回同期しても、
    一時間当たり21万ほどのカウンタが進むのだが、一年たっても限界には程遠いので問題にはならない。よほど細かく同期しないと限界は来ないと考えた方がいい。


    ・ID3D12FenceはマルチGPU間での同期待ちにも使える


    ちなみにID3D12Fence作成時に渡すD3D12_FENCE_FLAGSによっては複数のGPU間の同期用のID3D12Fenceも作ることができる。


    0 件のコメント:

    コメントを投稿