本文為大便一籮筐的原創內容,轉載請注明出處,謝謝:http://www.cnblogs.com/dbylk/p/4984530.html
公司引擎目前是使用CPU計算骨骼動畫(采用了D3DX提供的函數進行計算)在屏幕中存在大量角色時仍然對CPU造成了不小的壓力。根據VTune的性能檢測結果,300人同屏時,D3DXMatrixMultiply函數占用了5%的CPU時間(僅次於DrawCall的開銷),因此我想能不能把骨骼動畫的向量矩陣運算轉移到GPU中進行計算(即把骨骼相關的運算寫在着色器中),但通過打印公司模型的骨骼數量,發現有不少模型的骨骼數目超過了70,最多的有87根。因為公司的游戲是基於Dx9開發的,頂點着色器最多只支持256個常量寄存器,即使使用4x3矩陣也放不下這么多骨骼(除非讓美術。。。)。
而且我也不能保證在公司的項目中使用GPU計算骨骼動畫對性能的影響一定是正向的。因為剛來公司的時候,導師就讓我寫了一個播放模型動畫的小demo作為訓練,最開始我是用C++寫骨骼動畫,后來自己又用空余的時間寫了一版用着色器計算骨骼動畫的demo,結果性能對比發現C++計算骨骼動畫的平均fps在500左右,而着色器計算骨骼動畫的平均fps在4000左右,整整差了8倍!(不過這應該也跟我計算骨骼動畫的C++代碼效率寫得不高有關,因為我當時用的是自己寫的空間變換矩陣生成函數和矩陣向量乘法函數。不過根據一些論壇里的前輩提供的經驗,即使使用SIMD技術對我寫的函數進行優化,效率提升應該也在3倍以內,不至於造成如此大的差距。)為此我專門去問了一下導師,導師說他曾經也嘗試過使用着色器計算骨骼動畫,但是發現幀數反而更低了,所以一直沒有對公司引擎的這一部分做修改,如果我有興趣的話可以自己改一下,對比一下效率。然而這話說完沒多久,導師就跳槽了,所以目前本人處於無人指導,自己胡亂摸索的階段。。。小公司的悲哀T_T。。。
言歸正轉,因為導師不在公司了,所以我也沒有辦法知道他之前測試的時候着色器計算骨骼動畫為什么會幀數更低的細節。雖然從理論和常識上來看,GPU應該比CPU更適合做這方面的運算,但考慮到造成游戲幀數並不單單只受限於CPU或GPU的運算性能,還會受到CPU/GPU內存同步、硬盤讀寫、網絡狀況等等各方面因素的制約,所以我也不敢貿然下定論。況且改寫這方面的代碼是一個大工程,不是一時半會就能改完的,如果寫出來效率不如以前的話心血就白費了。。。為此我就想看看網上有沒有前輩對“在CPU與GPU計算骨骼動畫的性能”方面寫過相關的分析與對比,搜到的結果一邊倒——骨骼動畫使用GPU計算性能更高。不過也有不少人提到了常量寄存器對骨骼數目的限制因素,想想公司項目模型的87根骨骼,我的心又涼了半截。不過很快,大便我搜到了下面這篇博客:
看完后,我覺得文章中提到的技術實用性很高,於是我便打算在公司的項目中嘗試一下。考慮到既然是使用CPU計算骨骼動畫,要想讓性能達到極致,怎么能忘了之前提到的SIMD技術。然而大便我之前對SIMD只是有所耳聞,並沒有親自使用過,所以自然要再搜索一番 —3—)。。。
結果搜到了下面這個東西:
上面這篇貼子的樓主在13樓回復了下面這段話:
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: