嘗試優化骨骼動畫計算的意外收穫——使用嵌入式彙編對float轉int進行優化

本文爲大便一籮筐的原創內容,轉載請註明出處,謝謝:http://www.cnblogs.com/dbylk/p/4984530.htmlhtml

 


 

公司引擎目前是使用CPU計算骨骼動畫(採用了D3DX提供的函數進行計算)在屏幕中存在大量角色時仍然對CPU形成了不小的壓力。根據VTune的性能檢測結果,300人同屏時,D3DXMatrixMultiply函數佔用了5%的CPU時間(僅次於DrawCall的開銷),所以我想能不能把骨骼動畫的向量矩陣運算轉移到GPU中進行計算(即把骨骼相關的運算寫在着色器中),但經過打印公司模型的骨骼數量,發現有很多模型的骨骼數目超過了70,最多的有87根。由於公司的遊戲是基於Dx9開發的,頂點着色器最多隻支持256個常量寄存器,即便使用4x3矩陣也放不下這麼多骨骼(除非讓美術。。。)。ios

並且我也不能保證在公司的項目中使用GPU計算骨骼動畫對性能的影響必定是正向的。由於剛來公司的時候,導師就讓我寫了一個播放模型動畫的小demo做爲訓練,最開始我是用C++寫骨骼動畫,後來本身又用空餘的時間寫了一版用着色器計算骨骼動畫的demo,結果性能對比發現C++計算骨骼動畫的平均fps在500左右,而着色器計算骨骼動畫的平均fps在4000左右,整整差了8倍!(不過這應該也跟我計算骨骼動畫的C++代碼效率寫得不高有關,由於我當時用的是本身寫的空間變換矩陣生成函數和矩陣向量乘法函數。不過根據一些論壇裏的前輩提供的經驗,即便使用SIMD技術對我寫的函數進行優化,效率提高應該也在3倍之內,不至於形成如此大的差距。)爲此我專門去問了一下導師,導師說他曾經也嘗試過使用着色器計算骨骼動畫,可是發現幀數反而更低了,因此一直沒有對公司引擎的這一部分作修改,若是我有興趣的話能夠本身改一下,對比一下效率。然而這話說完沒多久,導師就跳槽了,因此目前本人處於無人指導,本身胡亂摸索的階段。。。小公司的悲哀T_T。。。git

言歸正轉,由於導師不在公司了,因此我也沒有辦法知道他以前測試的時候着色器計算骨骼動畫爲何會幀數更低的細節。雖然從理論和常識上來看,GPU應該比CPU更適合作這方面的運算,但考慮到形成遊戲幀數並不僅僅只受限於CPU或GPU的運算性能,還會受到CPU/GPU內存同步、硬盤讀寫、網絡情況等等各方面因素的制約,因此我也不敢貿然下定論。何況改寫這方面的代碼是一個大工程,不是一時半會就能改完的,若是寫出來效率不如之前的話心血就白費了。。。爲此我就想看看網上有沒有前輩對「在CPU與GPU計算骨骼動畫的性能」方面寫過相關的分析與對比,搜到的結果一邊倒——骨骼動畫使用GPU計算性能更高。不過也有很多人提到了常量寄存器對骨骼數目的限制因素,想一想公司項目模型的87根骨骼,個人心又涼了半截。不過很快,大便我搜到了下面這篇博客:github

一種簡單有效的3D模型的動畫多線程方案算法

看完後,我以爲文章中提到的技術實用性很高,因而我便打算在公司的項目中嘗試一下。考慮到既然是使用CPU計算骨骼動畫,要想讓性能達到極致,怎麼能忘了以前提到的SIMD技術。然而大便我以前對SIMD只是有所耳聞,並無親自使用過,因此天然要再搜索一番 —3—)。。。數組

結果搜到了下面這個東西:網絡

爲何使用SSE指令沒有性能提高多線程

上面這篇貼子的樓主在13樓回覆了下面這段話:dom

TimothyField:
 
這個問題昨天晚上已經基本解決,由於我已經連續發了3個帖子,系統不讓我繼續發,因此沒有及時更新。

首先要感謝polytechnic的提醒,我又仔細檢查了各個部分單獨花的時間,由於沒有合適的工具,我是經過簡單註釋掉部分代碼看執行時間的變化來查找疑點的。前面提到註釋掉SSE代碼的時候我是把相關的代碼也註釋掉了,如今再下降註釋的粒度。

首先注意到其實性能瓶頸確實不在SSE代碼部分,而是FastExp函數。這確實有點出乎意料,由於這個函數只是簡單的一個查表:
inline float TFastExp::Exp(float x)
{
    int n = (int)100*x;
    return data[n];
}
因爲知道x的範圍,因此連參數檢查都沒有,這樣的一個函數怎麼會成爲性能瓶頸呢?

我剛開始是懷疑因爲n的取值變化比較大,因此data[n]的訪問致使大量的cache missing,因此專門寫了一段相似的程序模擬測試,數組的索引用n*31%size模擬隨機訪問(random函數太慢了),結果並無發現相似的現象。

因而惟一的一個可能緣由就是浮點數到整數的轉換了。C編譯器產生的浮點到整數的轉換比較慢我是知道的,但到底多慢就沒有概念了,好在驗證起來比較簡單,我把n設置爲一個固定的整數,執行時間一會兒就縮短了。

知道緣由以後就比較容易解決了,如今已經把這個函數改寫爲:
float TFastExp::Exp(float x)
{
    int n;
    float y = 100*x;
    _asm fld y
    _asm fistp n
    return data[n];
}

用兩條彙編指令,6個時鐘週期搞定。(由於inline函數中不能使用嵌入式彙編,因此這個函數再也不加上inline)

這個地方修改以後,程序執行時間一降低低到106秒。平均單個循環只須要150個CPU TICK左右,比較原來須要570個CPU TICK,能夠猜想一個浮點數到整數的轉換在C++ Builder的缺省實現中須要約400個時鐘週期!!!這個猜想比較嚇人,但確實是如今獲得的數據暗示的結論。

再從新比較一下不使用SSE指令的C++版本算法,實測執行時間是248秒,也就是說使用SSE指令進一步循環展開後,執行時間下降到不使用SSE版本的約1/2.5。這跟原來指望差很少了。

「浮點數到整數的轉換」,這不跟我以前優化的那個GetMatrixKey函數有關係嗎?!函數

 

下面要介紹一下GetMatrixKey這個函數(我會關注到它徹底是由於VTune,不然這麼一個小函數根本想不到它會成爲性能殺手,佔用的CPU時間僅次於D3DXMatrixMultiply排在第三)。在我第一次看見它的時候,它是長這樣的:

// Author:大便一籮筐)
D3DXMATRIX* XXXXX::GetMatrixKey(KeyMatrix* pArray, int nCount, int nFrame) {
    if (nCount == 0) {
        return NULL;
    }

 // 幀數必定是i, i+1, i+2…連續輸出的
    int nStartFrame = static_cast<int>(pArray[0].Frame);
    if (nStartFrame >= nFrame) {
        return &pArray[0].Matrix;
    }

    if (nFrame >= GET_END_FROME_START(nCount, nStartFrame)) {
        return &pArray[nCount - 1].Matrix;
    }

    if (int(pArray[nFrame - nStartFrame].fFrame) != nFrame) {
        printf("\n幀數%d 起始幀%d 結束幀%d %s\n", nFrame, nStartFrame, int(pArray[nFrame-nStartFrame].fFrame), __FUNCTION__);
    }

    return &pArray[nFrame-nStartFrame].Matrix;
}

// 函數中用到的GET_END_FROM_START宏定義以下
#define GET_END_FROM_START(nCount, nStart) ((nCount)+(nStart)-1)

// 函數參數中用到的KeyMatrix參數定義以下
class KeyMatrix {
public:
    float fFrame;
    D3DXMATRIX Matrix;
}

首先我要吐槽一下KeyMatrix這個類:

  • 我不知道爲何表示變換的矩陣要和它對應的幀數一塊兒存在這樣一個類裏(根據搜索結果fFrame除了這個函數根本沒有其餘地方用到)
  • 並且爲何要把幀數fFrame定義成浮點類型(根據這個函數原來有的註釋:「幀數必定是i, i+1, i+2…連續輸出的」,能夠知道fFrame是整數,因此這裏用到的時候要把它轉成int)

由於KeyMatrix類被用在了動畫類裏,它所涉及的數據都被存在了遊戲模型的動畫文件裏,因此貿然修改它不是一個明智的決定。

 

「GetMatrixKey這個函數的做用是根據輸入的幀數nFrame返回pArray數組中對應的KeyMatrix中的矩陣。」

上面這個結論是我盯着這個函數看了幾分鐘之後才得出的,由於這個函數中使用了一個宏定義「GET_END_FROM_START」,讓我初看時認爲這個函數必定很是複雜。結果把宏定義套進函數再仔細一看,才發現這個函數的主要做用就是作數組範圍檢查,判斷nFrame有木有越界!一個檢查數組越界的函數寫得如此複雜(各類重複計算,在頻繁調用的函數裏執行沒必要要的打印,使用沒有必要的宏定義),簡直不能忍。。。

隨後,我把這個函數簡單地修改了一下:

// Author : 大便一籮筐 
inline D3DXMATRIX* XXXXX::GetMatrixKey(KeyMatrix* pArray, int nCount, int nFrame) {
    if (!nCount) {
        return NULL;
    }

    int nStartFrame = static_cast<int>(pArray[0].fFrame);
    int nIndex = nFrame - nStartFrame;
    
    if (nIndex < 0) {
        nIndex = 0;
    }

    if (nIndex >= nCount) {
        nIndex = nCount - 1;
    }

    return &pArray[nIndex].Matrix;
}

修改之後,我又用VTune測了一下性能,發現此函數的CPU時間降到了修改前的40%,雖然優化效果比較明顯,但依然佔用了很多的CPU時間。「這麼一個簡單的函數也要佔用這麼多CPU時間,也許是調用的次數太多了吧」,當時我是這麼想的。

 

如今看了CSDN這篇貼子,原來這個函數的性能消耗主要是在不起眼的基本數據類型的轉換上,着實給我上了一課。

我立刻打開VS2013,用以前本身寫的性能測試工具測了一下float到int直接轉換與CSDN貼子中樓主TimothyField提供的方法的開銷,結果卻讓我感到很是意外——VS2013的Debug模式下編譯出來的程序,在執行50,000,000次轉換時,float到int直接轉換消耗的時間比TimothyField提供的方法消耗時間少0.8s,也就是說直接轉換的效率更高。這讓我感到很是奇怪,但大便我立刻注意到了TimothyField在貼子中提到他使用到編譯器是C++ Builder,「也許是VS的編譯器在轉換中作了優化,使它比TimothyField提供的彙編更高效?」。爲了確認這一點,我打開了VS調試模式中的反彙編窗口,想看看這兩種轉換的彙編代碼有什麼不一樣,結果發現了下面這個指令:

cvttss2si   eax,xmm0

立刻打開網頁搜索了一番,發現原來這個指令也是SSE指令集中的指令,它的做用是提供更高效的float到int的截斷型轉換。想必是C++ Builder並無在默認轉換中使用這個指令,才使得他的默認轉換比fld和fistp指令更低效。

然而公司項目使用的仍是VS2008編譯器,會不會也沒有默認使用cvttss2si指令呢?實踐出真知,我立刻按下了F5,打開反編譯窗口查看了相應的彙編指令,發現VS2008果真沒有使用cvttss2si指令,而是調用了一個float轉int的函數(當時忘記給相應的彙編指令截圖了,名字忘記了)。

我火燒眉毛地想要把公司項目中的float到int型的轉換所有替換爲cvttss2si指令了,不過仍是再單獨測試一下這個指令的效率比較好,因而我參考了VS2013直接轉換的反彙編,又寫了一個函數作測試:

// Author : 大便一籮筐

inline void SseAsmCast() {
    for (int i = 0; i < nCalculation; ++i) {
        float fTemp = fDenominator * fNumber;
        int iTemp;

        _asm cvttss2si eax, fTemp
        _asm mov       iTemp,eax
        
        fNumber = fTable[iTemp];
    }
}

然而測試結果卻再一次讓我大跌眼鏡,即便使用了cvttss2si指令,消耗的時間也和使用fld + fistp指令同樣,遠低於VS2013默認轉換的效率。爲此,我考慮到可能VS2013在默認轉換的過程當中優化掉了臨時變量iTemp與fTemp,直接使用32位寄存器(eax/ebx/ecx/edx)存儲中間結果,因此纔會有更高的效率,因而我又增長了幾條彙編指令,避免了了iTemp與fTemp的定義:

// Author : 大便一籮筐 
 
inline void SseAsmCast() {
    for (unsigned int i = 0; i < nCalculation; ++i) {
        _asm {
            movss        xmm0, fNumber
            mulss        xmm0, fDenominator
            cvttss2si    eax, xmm0
            mov          ebx,fTable
            movss        xmm0,dword ptr [ebx+eax*4]
            movss        fNumber,xmm0
        }
    }
}

這一次,在Debug模式下,彙編指令的效率超越了直接轉換的效率,但當我使用Release模式測試時,發現VS2013的直接轉換效率再次超越了上面的彙編指令。

爲此,我又查看了一下Release模式下的反彙編代碼,發現VS在Release模式下還作了一個優化,那就是省略了循環體中的「movss xmm0,fNumber」這條指令,直接使用上一次循環中的xmm0寄存器參與乘法運算,爲了驗證,我又將彙編指令的轉換函數改寫以下:

// Author : 大便一籮筐

inline void SseAsmCast() {
    _asm movss        xmm0, fNumber

    for (unsigned int i = 0; i < nCalculation; ++i) {
        _asm {
            mulss        xmm0, fDenominator
            cvttss2si    eax, xmm0
            mov          ebx,fTable
            movss        xmm0,dword ptr [ebx+eax*4]
            movss        fNumber,xmm0
        }
    }
}

這一次的測試結果證明了個人想法,上面的彙編指令與VS2013編譯出來的直接轉換效率至關,甚至還要稍微高效一點(Release模式下50,000,000次轉換節省0.03s,整個函數約有10%的效率提高)。

 

最後得出的結論是:若是發現你所使用的編譯器沒有使用SSE指令執行float到int型的轉換,能夠手動使用內聯彙編對程序進行優化

 

整個驗證程序的源碼以下:

// Author : 大便一籮筐

#pragma comment(lib, "TestUtils.lib")

#include "../TestUtils/DB_Log.h"
#include "../TestUtils/DB_Timer.h"

#include <iostream>

using namespace std;
using namespace DaBianYLK;

#define FLOAT_TO_INT(f, i) _asm fld f _asm fistp i

float* fTable = new float[1024];
const float fDenominator = 3.3f;
float fNumber = 1.0f;
const unsigned int nCalculation = 50000000;

inline void SetupFloatTable() {
    for (unsigned i = 0; i < 1023; ++i) {
        fTable[i] = (i + 1 + 0.33f) / fDenominator;
    }

    fTable[1023] = 1.0f / fDenominator;
}

inline void DirectCast() {
    for (unsigned int i = 0; i < nCalculation; ++i) {
        int iTemp = fDenominator * fNumber;

        fNumber = fTable[iTemp];
    }
}

inline void SseAsmCast() {
    _asm movss        xmm0, fNumber

    for (unsigned int i = 0; i < nCalculation; ++i) {
        _asm {
            mulss        xmm0, fDenominator
            cvttss2si    eax, xmm0
            mov          ebx,fTable
            movss        xmm0,dword ptr [ebx+eax*4]
            movss        fNumber,xmm0
        }
    }
}

inline void NormalAsmCast() {
    for (unsigned int i = 0; i < nCalculation; ++i) {
        float fTemp = fDenominator * fNumber;
        int iTemp;

        _asm fld   fTemp
        _asm fistp iTemp

        fNumber = fTable[iTemp];
    }
}

inline void StaticCast() {
    for (unsigned int i = 0; i < nCalculation; ++i) {
        int iTemp = static_cast<int>(fDenominator * fNumber);

        fNumber = fTable[iTemp];
    }
}

int main(void) {
    SetupFloatTable();

    // 直接轉換
    fNumber = 1.0f;
    BENCHMARK(DirectCast, DirectCast());
    Log("FNumber : %f", fNumber);

    // Trick
    fNumber = 1.0f;
    BENCHMARK(SseAsmCast, SseAsmCast());
    Log("FNumber : %f", fNumber);

    // Trick
    fNumber = 1.0f;
    BENCHMARK(NormalAsmCast, NormalAsmCast());
    Log("FNumber : %f", fNumber);

    // 靜態轉換
    fNumber = 1.0f;
    BENCHMARK(StaticCast, StaticCast());
    Log("FNumber : %f", fNumber);            // 至少要輸出一次fNumber,不然編譯器的優化會刪除執行運算的代碼

    system("pause");

    return 0;
}

 

其中BENCHMARK宏是我編寫的性能測試工具,它的源碼開放在了我我的的GitHub:

https://github.com/DaBianYLK/TestProjects

相關文章
相關標籤/搜索