DirectX11--CPU與GPU計時器

X_Jun發表於2022-05-20

前言

GAMES104的王希說過:

遊戲引擎的世界裡,它的核心是靠Tick()函式把這個世界驅動起來。

本來單是一個CPU的計時器是不至於為其寫一篇部落格的,但把GPU計時器功能加上後就不一樣了。在這一篇中,我們將講述如何使用CPU計時器獲取幀間隔,以及使用GPU計時器獲取GPU中執行一系列指令的間隔。

DirectX11 With Windows SDK完整目錄

歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裡彙報。

CPU計時器

在遊戲中,我們需要用到高精度的計時器。在這裡我們直接使用龍書的GameTimer,但為了區分後續的GPU計時器,現在將其改名為CpuTimer

class CpuTimer
{
public:
    CpuTimer();
 
    float TotalTime()const;     // 返回從Reset()呼叫之後經過的時間,但不包括暫停期間的
    float DeltaTime()const;	    // 返回幀間隔時間

    void Reset();               // 計時開始前或者需要重置時呼叫
    void Start();               // 在開始計時或取消暫停的時候呼叫
    void Stop();                // 在需要暫停的時候呼叫
    void Tick();                // 在每一幀開始的時候呼叫
    bool IsStopped() const;     // 計時器是否暫停/結束

private:
    double m_SecondsPerCount = 0.0;
    double m_DeltaTime = -1.0;

    __int64 m_BaseTime = 0;
    __int64 m_PausedTime = 0;
    __int64 m_StopTime = 0;
    __int64 m_PrevTime = 0;
    __int64 m_CurrTime = 0;

    bool m_Stopped = false;
};

在建構函式中,我們將查詢計算機performance counter的頻率,因為該頻率對於當前CPU是固定的,我們只需要在初始化階段獲取即可。然後我們可以求出單個count經過的時間:

CpuTimer::CpuTimer()
{
    __int64 countsPerSec{};
    QueryPerformanceFrequency((LARGE_INTEGER*)&countsPerSec);
    m_SecondsPerCount = 1.0 / (double)countsPerSec;
}

在開始使用計數器之前,或者想要重置計時器時,我們需要呼叫一次Reset(),以當前時間作為基準時間。這些__int64的型別儲存的單位為count:

void CpuTimer::Reset()
{
    __int64 currTime{};
    QueryPerformanceCounter((LARGE_INTEGER*)&currTime);

    m_BaseTime = currTime;
    m_PrevTime = currTime;
    m_StopTime = 0;
    m_PausedTime = 0;	// 涉及到多次Reset的話需要將其歸0
    m_Stopped  = false;
}

然後這裡我們先看Stop()的實現,就是記錄當前Stop的時間和標記為暫停中:

void CpuTimer::Stop()
{
    if( !m_Stopped )
    {
        __int64 currTime{};
        QueryPerformanceCounter((LARGE_INTEGER*)&currTime);

        m_StopTime = currTime;
        m_Stopped  = true;
    }
}

在呼叫Reset()完成初始化後,我們就可以呼叫Start()啟動計時了。當然如果之前呼叫過Stop()的話,將當前Stop()Start()經過的暫停時間累加到總的暫停時間:

void CpuTimer::Start()
{
    __int64 startTime{};
    QueryPerformanceCounter((LARGE_INTEGER*)&startTime);


    // 累積暫停開始到暫停結束的這段時間
    //
    //                     |<-------d------->|
    // ----*---------------*-----------------*------------> time
    //  m_BaseTime       m_StopTime        startTime     

    if( m_Stopped )
    {
        m_PausedTime += (startTime - m_StopTime);

        m_PrevTime = startTime;
        m_StopTime = 0;
        m_Stopped  = false;
    }
}

然後在每一幀開始之前呼叫Tick()函式,更新當前幀與上一幀之間的間隔時間,該用時通過DeltaTime()獲取,可以用於物理世界的更新:

void CpuTimer::Tick()
{
    if( m_Stopped )
    {
        m_DeltaTime = 0.0;
        return;
    }

    __int64 currTime{};
    QueryPerformanceCounter((LARGE_INTEGER*)&currTime);
    m_CurrTime = currTime;

    // 當前Tick與上一Tick的幀間隔
    m_DeltaTime = (m_CurrTime - m_PrevTime)*m_SecondsPerCount;

    m_PrevTime = m_CurrTime;

    if(m_DeltaTime < 0.0)
    {
        m_DeltaTime = 0.0;
    }
}

float CpuTimer::DeltaTime() const
{
    return (float)m_DeltaTime;
}

如果要獲取遊戲開始到現在經過的時間(不包括暫停期間),可以使用TotalTime()

float CpuTimer::TotalTime()const
{
    // 如果呼叫了Stop(),暫停中的這段時間我們不需要計入。此外
    // m_StopTime - m_BaseTime可能會包含之前的暫停時間,為
    // 此我們可以從m_StopTime減去之前累積的暫停的時間
    //
    //                     |<-- 暫停的時間 -->|
    // ----*---------------*-----------------*------------*------------*------> time
    //  m_BaseTime       m_StopTime        startTime     m_StopTime    m_CurrTime

    if( m_Stopped )
    {
        return (float)(((m_StopTime - m_PausedTime)-m_BaseTime)*m_SecondsPerCount);
    }

    // m_CurrTime - m_BaseTime包含暫停時間,但我們不想將它計入。
    // 為此我們可以從m_CurrTime減去之前累積的暫停的時間
    //
    //  (m_CurrTime - m_PausedTime) - m_BaseTime 
    //
    //                     |<-- 暫停的時間 -->|
    // ----*---------------*-----------------*------------*------> time
    //  m_BaseTime       m_StopTime        startTime     m_CurrTime
    
    else
    {
        return (float)(((m_CurrTime-m_PausedTime)-m_BaseTime)*m_SecondsPerCount);
    }
}

總的來說,正常的呼叫順序是Reset()Start(),然後每一幀呼叫Tick(),並獲取DeltaTime()。在需要暫停的時候就Stop(),恢復用Start()

GPU計時器

假如我們需要統計某一個渲染過程的用時,如後處理、場景渲染、陰影繪製等,可能有人的想法是這樣的:

timer.Start();
DrawSomething();
timer.Tick();
float deltaTime = timer.DeltaTime();

實際上這樣並不能測量,因為CPU跟GPU是非同步執行的。裝置上下文所呼叫的大部分方法實際上是向顯示卡塞入命令然後立刻返回,這些命令被快取到一個命令佇列中等待被消化。

因此,如果要測量GPU中一段執行過程的用時,我們需要向GPU插入兩個時間戳,然後將這兩個時間戳的Tick Count回讀到CPU,最後通過GPU獲取這期間的頻率來求出間隔。

目前GpuTimer放在Common資料夾中,供36章以後的專案使用,後續會考慮放到之前的專案中。

GpuTimer類的宣告如下:

class GpuTimer
{
public:
    GpuTimer() = default;
    
    // recentCount為0時統計所有間隔的平均值
    // 否則統計最近N幀間隔的平均值
    void Init(ID3D11Device* device, ID3D11DeviceContext* deviceContext, size_t recentCount = 0);
    
    // 重置平均用時
    // recentCount為0時統計所有間隔的平均值
    // 否則統計最近N幀間隔的平均值
    void Reset(ID3D11DeviceContext* deviceContext, size_t recentCount = 0);
    // 給命令佇列插入起始時間戳
    HRESULT Start();
    // 給命令佇列插入結束時間戳
    void Stop();
    // 嘗試獲取間隔
    bool TryGetTime(double* pOut);
    // 強制獲取間隔(可能會造成阻塞)
    double GetTime();
    // 計算平均用時
    double AverageTime()
    {
        if (m_RecentCount)
            return m_AccumTime / m_DeltaTimes.size();
        else
            return m_AccumTime / m_AccumCount;
    }

private:
    
    static bool GetQueryDataHelper(ID3D11DeviceContext* pContext, bool loopUntilDone, ID3D11Query* query, void* data, uint32_t dataSize);
    

    std::deque<double> m_DeltaTimes;    // 最近N幀的查詢間隔
    double m_AccumTime = 0.0;           // 查詢間隔的累計總和
    size_t m_AccumCount = 0;            // 完成回讀的查詢次數
    size_t m_RecentCount = 0;           // 保留最近N幀,0則包含所有

    std::deque<GpuTimerInfo> m_Queries; // 快取未完成的查詢
    Microsoft::WRL::ComPtr<ID3D11Device> m_pDevice;
    Microsoft::WRL::ComPtr<ID3D11DeviceContext> m_pImmediateContext;
};

其中,Init()用於獲取D3D裝置和裝置上下文,並根據recentCount確定要統計最近N幀間隔的平均值,還是所有間隔的平均值:

void GpuTimer::Init(ID3D11Device* device, ID3D11DeviceContext* deviceContext, size_t recentCount)
{
    m_pDevice = device;
    m_pImmediateContext = deviceContext;
    m_RecentCount = recentCount;
    m_AccumTime = 0.0;
    m_AccumCount = 0;
}

在呼叫Init()後,我們就可以開始呼叫Start()來給命令佇列插入起始時間戳了。但在此之前,我們需要先介紹我們需要給命令佇列插入的具體是什麼。

ID3D11Device::CreateQueue--建立GPU查詢

為了建立GPU查詢,我們需要先填充D3D11_QUERY_DESC結構體:

typedef struct D3D11_QUERY_DESC {
  D3D11_QUERY Query;
  UINT        MiscFlags;  // 目前填0
} D3D11_QUERY_DESC;

關於列舉型別D3D11_QUERY,我們現在只關注其中兩個列舉值:

  • D3D11_QUERY_TIMESTAMP:通過ID3D11DeviceContext::GetData返回的UINT64表示的是一個時間戳的值。該查詢還需要D3D11_QUERY_TIMESTAMP_DISJOINT的配合來判斷當前查詢是否有效。
  • D3D11_QUERY_TIMESTAMP_DISJOINT:用來確定當前的D3D11_QUERY_TIMESTAMP是否返回可信的結果,並可以獲取當前流處理器的頻率,來允許你將這兩個tick變換成經過的時間來求出間隔。該查詢只應該在每幀或多幀中執行一次,然後通過ID3D11DeviceContext::GetData返回D3D11_QUERY_DATA_TIMESTAMP_DISJOINT

D3D11_QUERY_DATA_TIMESTAMP_DISJOINT的結構體如下:

typedef struct D3D11_QUERY_DATA_TIMESTAMP_DISJOINT {
  UINT64 Frequency;   // 當前GPU每秒增加的counter數目
  BOOL   Disjoint;    // 僅當其為false時,兩個時間戳的詢問才是有效的,表明這期間的頻率是固定的
                      // 若為true,說明可能出現了拔開筆記本電源、過熱、由於節點模式導致的功耗降低等
} D3D11_QUERY_DATA_TIMESTAMP_DISJOINT;

由於從GPU回讀資料是一件很慢的事情,可能會拖慢1幀到幾幀,為此我們需要把建立好的時間戳和頻率/連續性查詢先快取起來。這裡使用的是GpuTimerInfo

struct GpuTimerInfo
{
    D3D11_QUERY_DATA_TIMESTAMP_DISJOINT disjointData {};  // 頻率/連續性資訊
    uint64_t startData = 0;  // 起始時間戳
    uint64_t stopData = 0;   // 結束時間戳
    Microsoft::WRL::ComPtr<ID3D11Query> disjointQuery;    // 連續性查詢
    Microsoft::WRL::ComPtr<ID3D11Query> startQuery;       // 起始時間戳查詢
    Microsoft::WRL::ComPtr<ID3D11Query> stopQuery;        // 結束時間戳查詢
    bool isStopped = false;                               // 是否插入了結束時間戳
};

Start()中我們需要同時建立查詢、插入時間戳、開始連續性/頻率查詢。

HRESULT GpuTimer::Start()
{
    if (!m_Queries.empty() && !m_Queries.back().isStopped)
        return E_FAIL;

    GpuTimerInfo& info = m_Queries.emplace_back();
    CD3D11_QUERY_DESC queryDesc(D3D11_QUERY_TIMESTAMP);
    m_pDevice->CreateQuery(&queryDesc, info.startQuery.GetAddressOf());
    m_pDevice->CreateQuery(&queryDesc, info.stopQuery.GetAddressOf());
    queryDesc.Query = D3D11_QUERY_TIMESTAMP_DISJOINT;
    m_pDevice->CreateQuery(&queryDesc, info.disjointQuery.GetAddressOf());

    m_pImmediateContext->Begin(info.disjointQuery.Get());
    m_pImmediateContext->End(info.startQuery.Get());
    return S_OK;
}

需要注意的是,D3D11_QUERY_TIMESTAMP只通過ID3D11DeviceContext::End來插入起始時間戳;D3D11_QUERY_TIMESTAMP_DISJOINT則需要區分``ID3D11DeviceContext::BeginID3D11DeviceContext::End`。

在完成某個特效渲染後,我們可以呼叫Stop()來插入結束時間戳,並完成連續性/頻率的查詢:

void GpuTimer::Stop()
{
    GpuTimerInfo& info = m_Queries.back();
    m_pImmediateContext->End(info.disjointQuery.Get());
    m_pImmediateContext->End(info.stopQuery.Get());
    info.isStopped = true;
}

呼叫Stop()後,這時我們還不一定能夠拿到間隔。考慮到執行時的效能分析考慮的是多間隔求平均,我們可以接受延遲幾幀的回讀。為此,我們可以使用TryGetTime(),嘗試對時間最久遠、仍未完成的查詢嘗試GPU回讀:

bool GpuTimer::GetQueryDataHelper(ID3D11DeviceContext* pContext, bool loopUntilDone, ID3D11Query* query, void* data, uint32_t dataSize)
{
    if (query == nullptr)
        return false;

    HRESULT hr = S_OK;
    int attempts = 0;
    do
    {
        // 嘗試GPU回讀
        hr = pContext->GetData(query, data, dataSize, 0);
        if (hr == S_OK)
            return true;
        attempts++;
        if (attempts > 100)
            Sleep(1);
        if (attempts > 1000)
        {
            assert(false);
            return false;
        }
    } while (loopUntilDone && (hr == S_FALSE));
    return false;

bool GpuTimer::TryGetTime(double* pOut)
{
    if (m_Queries.empty())
        return false;

    GpuTimerInfo& info = m_Queries.front();
    if (!info.isStopped) return false;
    if (info.disjointQuery && !GetQueryDataHelper(m_pImmediateContext.Get(), false, info.disjointQuery.Get(), &info.disjointData, sizeof(info.disjointData)))
        return false;
    info.disjointQuery.Reset();

    if (info.startQuery && !GetQueryDataHelper(m_pImmediateContext.Get(), false, info.startQuery.Get(), &info.startData, sizeof(info.startData)))
        return false;
    info.startQuery.Reset();

    if (info.stopQuery && !GetQueryDataHelper(m_pImmediateContext.Get(), false, info.stopQuery.Get(), &info.stopData, sizeof(info.stopData)))
        return false;
    info.stopQuery.Reset();

    if (!info.disjointData.Disjoint)
    {
        double deltaTime = static_cast<double>(info.stopData - info.startData) / info.disjointData.Frequency;
        if (m_RecentCount > 0)
            m_DeltaTimes.push_back(deltaTime);
        m_AccumTime += deltaTime;
        m_AccumCount++;
        if (m_DeltaTimes.size() > m_RecentCount)
        {
            m_AccumTime -= m_DeltaTimes.front();
            m_DeltaTimes.pop_front();
        }
        if (pOut) *pOut = deltaTime;
    }
    else
    {
        double deltaTime = -1.0;
    }

    m_Queries.pop_front();
    return true;
}

如果你就是在當前幀獲取間隔,可以使用GetTime()

double GpuTimer::GetTime()
{
    if (m_Queries.empty())
        return -1.0;

    GpuTimerInfo& info = m_Queries.front();
    if (!info.isStopped) return -1.0;

    if (info.disjointQuery)
    {
        GetQueryDataHelper(m_pImmediateContext.Get(), true, info.disjointQuery.Get(), &info.disjointData, sizeof(info.disjointData));
        info.disjointQuery.Reset();
    }
    if (info.startQuery)
    {
        GetQueryDataHelper(m_pImmediateContext.Get(), true, info.startQuery.Get(), &info.startData, sizeof(info.startData));
        info.startQuery.Reset();
    }
    if (info.stopQuery)
    {
        GetQueryDataHelper(m_pImmediateContext.Get(), true, info.stopQuery.Get(), &info.stopData, sizeof(info.stopData));
        info.stopQuery.Reset();
    }

    double deltaTime = -1.0;
    if (!info.disjointData.Disjoint)
    {
        deltaTime = static_cast<double>(info.stopData - info.startData) / info.disjointData.Frequency;
        if (m_RecentCount > 0)
            m_DeltaTimes.push_back(deltaTime);
        m_AccumTime += deltaTime;
        m_AccumCount++;
        if (m_DeltaTimes.size() > m_RecentCount)
        {
            m_AccumTime -= m_DeltaTimes.front();
            m_DeltaTimes.pop_front();
        }
    }

    m_Queries.pop_front();
    return deltaTime;
}

重置GPU計時器的話使用Reset()方法:

void GpuTimer::Reset(ID3D11DeviceContext* deviceContext, size_t recentCount)
{
    m_Queries.clear();
    m_DeltaTimes.clear();
    m_pImmediateContext = deviceContext;
    m_AccumTime = 0.0;
    m_AccumCount = 0;
    if (recentCount)
        m_RecentCount = recentCount;
}

下面的程式碼展示如何使用GPU計時器:

m_GpuTimer.Init(m_pd3dDevice.Get(), m_pd3dImmediateContext.Get());



// ...
m_GpuTimer.Start();
{
    // 一些繪製過程...
}
m_GpuTimer.Stop();

// ...
m_GpuTimer.TryGetTime(nullptr); // 只是為了更新下面的平均值
float avgTime = m_GpuTimer.AverageTime();

下面是分塊延遲渲染統計各個pass用時的例子:

image

注意:如果遊戲開啟了垂直同步,那麼當前幀中的某一個查詢很可能會受到垂直同步的影響被拖長,從而導致原本當前幀GPU計時器的平均用時總和會接近兩個垂直同步訊號的間隔。以下圖為例

image

DirectX11 With Windows SDK完整目錄

歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裡彙報。

相關文章