NEUROMANTIC

自分でC/C++/UE4/Graphics/ゲームなどをやったことをメモするブログ

DirectX11でGPUの作業時間を測定してみた

f:id:neuliliilli:20190506215256p:plain
ImGuiで作業時間を測定する

reedbeta.com

DirectX11を勉強しながら、サンプルのGPUの各作業時間を測定するため検索してみたところ、ID3D11Queryというのを用いてGPUの時間を測定することがわかりました。 SH-D3D11サンドボックスプロジェクトのサンプル1と2を実装しながらID3D11Queryでどうやって測定してのかちょっとメモしたいと思います。

本論に入る前に、GPUの作業時間の測定をCPUの作業時間の測定値として一緒に扱ってはいけません。なぜならCPUのプラットフォームで提供している各種の時間測定APIはGPUを考慮しないからです。たとえQueryPerformanceCounterまたはstd::chrono::...::now()で最初時間と最後時間を測定したとしても、GPUはCPUとは別として動いてますからこの測定値数が本当に信頼性あるかわかりません。単にFPSでこれをOFFしたら10FPSが上がったというようにパフォーマンスを測定してはいけません。

まずID3D11Queryを使用するにはこのクエリタイプのインスタンスを作らなければなりません。ID3D11Device::CreateQueryで簡単に作ることが出来ます。 ここでディスクリプタにQueryの種類を書きますが、GPUのクロックを測定するためにはD3D11_QUERY_TIMESTAMPD3D11_QUERY_TIMESTAMP_DISJOINTが個別で必要です。

docs.microsoft.com

D3D11_QUERY_TIMESTAMP_DISJOINTのQueryは、後で説明するID3D11DeviceContext::BeginID3D11DeviceContext::Endを順に呼び出す時に必要になります。
D3D11_QUERY_TIMESTAMPID3D11DeviceContext::Endを呼び出す時のみ必要です。MSDNによるとこのタイプのクエリで、Beginは無効になるそうです。 ID3D11DeviceContextの上記のAPIを実行すると、GPUのクロックを記録するようになります。即ち、各自がストップウォッチみたいに動くわけではなく、Disjointクエリにより相対的なGPUクロック数が記録されるわけです。

D3D11_QUERY_TIMESTAMP_DISJOINTのクエリは、BeginEndによってGPUデバイスのクロック周波数を渡されます。これにより補助のタイムスタンプクエリから記録したクロックに用いて実際の時間として変換することが出来ます。また、これはあくまでもGPUからのコマンドによる実行になりますのでCPUとの時差などに気にしなくても良いそうです。

僕のサンプルコードはこう書いて、Disjointと一般Timestampクエリを作成しました。

// Make timestamp queries (disjoint and start-end queries)
// https://docs.microsoft.com/ko-kr/windows/desktop/api/d3d11/nf-d3d11-id3d11device-createquery
IComOwner<ID3D11Query> ownDisjointQuery       = *FD3D11Factory::CreateTimestampQuery(mD3DDevice.Get(), true);
IComOwner<ID3D11Query> ownGpuStartFrameQuery  = *FD3D11Factory::CreateTimestampQuery(mD3DDevice.Get(), false);
IComOwner<ID3D11Query> ownGpuEndFrameQuery    = *FD3D11Factory::CreateTimestampQuery(mD3DDevice.Get(), false);

std::optional<IComOwner<ID3D11Query>> FD3D11Factory::CreateTimestampQuery(
  ID3D11Device& device,
  bool isDisjoint)
{
  IComOwner<ID3D11Query> result = nullptr;

  D3D11_QUERY_DESC queryDesc;
  queryDesc.Query = isDisjoint == true ? D3D11_QUERY_TIMESTAMP_DISJOINT : D3D11_QUERY_TIMESTAMP;
  queryDesc.MiscFlags = 0;

  if (device.CreateQuery(&queryDesc, &result) != S_OK)
  {
    result.Release();
    return std::nullopt;
  }

  return result;
}

作成されたクエリはこのように実行出来ます。

auto gpuTime = MTimeChecker::CheckGpuD3D11Time(
  "GpuFrame", 
  ownDisjointQuery.Get(), mD3DImmediateContext.Get(), 
  false);
{
  auto fragment = 
    gpuTime.CheckFragment("Overall", ownGpuStartFrameQuery.Get(), ownGpuEndFrameQuery.Get());

  mD3DImmediateContext->ClearRenderTargetView(
    &mRenderTargetView.Get(), 
    std::array<FLOAT, 4>{bgCol.X, bgCol.Y, bgCol.Z, 1}.data());
  mD3DImmediateContext->ClearDepthStencilView(&mDepthStencilView.Get(), 
    D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL,
    1.0f,
    0);

  MGuiManager::Render();
  // Present the back buffer to the screen.
  HR(mD3DSwapChain->Present(1, 0));
} // RAII!
// And gpuTime will be destroyed and RAII happen.

私のサンプルのフレームワークでは素のままじゃなくてラッピングしてから、別のタイプのインスタンスをRAIIとして渡されて利用する形になります。gpuTimefragmentがそうですが、このインスタンスは始発のクエリと終わりのクエリを持っていて、RAIIにより自動的にBeginEndを呼ぶことになります。(DisjointクエリのみBeginを呼び出すことになっています)

そしてDisjointクエリのRAIIインスタンスが消滅になると、補助のタイムスタンプクエリを終了することと同時にクエリから貰ったGPUクロックを実際時間に変換します。

std::unordered_map<std::string, double>
FD3D11TimeHandle::CalculateTimestamps(
  ID3D11DeviceContext& dc, 
  ID3D11Query& disjoint, 
  TTimeFragments& fragments)
{
  std::unordered_map<std::string, double> results;

  // Stall until disjointQuery is available.
  while (dc.GetData(&disjoint, nullptr, 0, 0) == S_FALSE)
    ;

  // Check whether timestamps were disjoint during the last frame
  D3D11_QUERY_DATA_TIMESTAMP_DISJOINT tsDisjoint;
  dc.GetData(&disjoint, &tsDisjoint, sizeof(tsDisjoint), 0);
  if (tsDisjoint.Disjoint)
  {
    return results;
  }

  for (auto& [fragmentName, pair] : fragments)
  {
    auto& [start, end] = pair;

    // Get All the timestamps.
    UINT64 tsBeginFrame, tsEndFrame;
    dc.GetData(start, &tsBeginFrame, sizeof(UINT64), 0);
    dc.GetData(end, &tsEndFrame, sizeof(UINT64), 0);

    // Convert to real time (ms).
    const auto msGpuFrame = 
        double(tsEndFrame - tsBeginFrame) / double(tsDisjoint.Frequency) 
      * 1'000.0;
    results.try_emplace(fragmentName, msGpuFrame);
  }

  return results;
}

resultは各タイムスタンプクエリ(2つのクエリの参照を持つ)の作業時間をmsとして表したものをハッシュマップに入れたものとなります。これにより、ID3D11Queryを使用して各GPU作業の処理時間を求められるようになりました。

ちなみに

GPUのクロック数値を得るにはDisjointクエリの作業が終わらなければなりません。この為、上のコードではSpinlockみたいなコードを書いてDisjointが終わったかを検証してから値を得ます。

while (dc.GetData(&disjoint, nullptr, 0, 0) == S_FALSE)
  ;

こういったコードはCPUのStallを起こす恐れがありますので、スレッドを分けてプロファイリングスレッドで独自的に測定させるのがいいかもしれませんね。またこうなると、値を書き下ろすにはAtomicな演算を行わなければならなくなりますし、せめてBackbufferの測定値などを実装してより安全にGPU処理時間値を測らなければならなさそうです。(こっちのプロジェクトではまだやっていませんが、サンプルが複雑になるとやってみる考えはあります)