還記得創作者之夏裡的這一段嗎?我在艾莉兒身上用了很多提升速度的技巧,所以她的執行速度相當快,底層效能夠好寫上層就不太需要煩惱效能。
我製作艾莉兒是用重視速度和火力,犧牲易用性的設計,以RPG來比喻的話,她的速度快但耐久力低。
本篇介紹其中一招:修改calling convention,寫好定義之後讓編譯器自己處理就行了,不用自己寫演算法,是比較容易做的優化。
先只介紹PC用的x86架構,手機常用的ARM架構還沒實際寫過。
碰過組合語言才比較能體會calling convention是什麼,這裡簡單介紹一下。字面意思是「呼叫協議」,像C/C++之類編譯成機器碼的語言,編譯之後就沒有變數名稱和資料型態這些東西了,變成只是記憶體裡的一堆byte,要有個規則規定各個參數放在記憶體裡的哪個位置或暫存器,否則如果呼叫函式時把參數放在esp+4的位置,但函式裡以為參數在esp+8就會出錯了。
CPU內部的暫存器存取比CPU外面的主記憶體快,因此一個提升速度的技巧是儘量把參數放在暫存器,這樣儲存、計算都可以在CPU裡完成,不用跑到外面的記憶體。
下面提到的SSE全名Streaming SIMD Extensions,它的向量運算功能也可以用來加速,但想用也要懂一點組合語言,以後有機會再介紹。
-x86 32位元-這裡「32位元」指的是程式的位元數,而不是作業系統和CPU的位元數,如果在64位元OS執行32位元程式,那還是照32位元的規則。
32位元有兩個標準calling convention:cdecl和stdcall,都是把參數放在記憶體,最常用的兩個compiler:GCC和VC有提供方法改成用暫存器傳參數。
但這是非標準的編譯器特殊功能,所以最好只用在自己用的函式庫,或是exe(直接拿來執行,不會有其他程式call裡面的函式),如果要做成函式庫給別人用,那還是要照標準的方式。
GCC有兩個function attribute可以用:regparm和sseregparm,最多可以用3個整數暫存器和3個SSE暫存器,剩下的參數才放在記憶體,其中整數和指標放在整數暫存器,浮點數和向量放在SSE。
用法是宣告函式時這樣寫
int __attribute((regparm(3),sseregparm)) rectHitRect(const float* obj, float* v, const float* target); |
如果翻GCC的compiler option說明可以看到有兩個flag:-mregparm和-msseregparm,加上去則所有函式都會用上面的calling convention,可是在Linux用的話所有不是你寫的函式庫,包括系統函式都要用這個flag編譯才能用,所以還是得用一個一個函式指定的方式。
參考:GCC說明文件關於x86的部分,
function attribute、
compiler optionVC很久以來加速的方法只有fastcall,用兩個整數暫存器。VC 2013新增一個vectorcall,可以用2個整數和6個SSE暫存器放參數。(這是我在Cyber Sprite 2改用VC 2013的原因)
使用方法有二,一個是在函式宣告時加上__vectorcall
int __vectorcall rectHitRect(const float* obj, float* v, const float* target); |
另一個是編譯時命令列加上/Gv,這樣只要沒指定calling convention的函式都會用vectorcall。跟Linux環境不同,Windows API裡所有函式宣告都有指定calling convention,沒有不相容的問題。
參考:VC 2013說明文件,
vectorcall、
compiler option
-x86 64位元-64位元程式就沒有這種改造了,也比較不需要,因為標準就是用暫存器傳參數。x86_32發明的時候還沒有SSE,想用SSE得用後來新增非標準的方法,而x86_64出現的時候已經有SSE2了,設計時有把SSE考慮進去。
標準calling convention有這兩個。
sysv:用在Linux、Mac OSX等類UNIX系統。使用6個整數、8個SSE,前6個整數放在整數,前8個浮點數放在SSE,最多可以用14個暫存器。
Microsoft:用在Windows。4個整數或SSE,跟sysv不一樣的是,即使參數是整數、浮點數混合,也只有前4個放在暫存器,第5個以後的放記憶體。
Windows用VC的話,可以如32位元一樣加上__vectorcall,讓用的暫存器增加到4個整數、6個SSE。
關於calling convention可以配合使用兩個技巧。
第一個是static函式。global函式宣告成static代表「只能在這個編譯單位裡使用,不能讓其他編譯單位引用」,簡單說是只能在一個.c、.cpp檔裡使用。
(這和函式內的static變數及class的static member是不同的東西,C/C++把同一個static關鍵字用在不同的地方)
static int pointHitSegment(const float* obj, float* v,const float* self){ …… }
static int pointHitCircle(const float* obj, float* v,const float* self){ …… } |
compiler確定函式不會在其他地方被用到,就可以做最佳化,例如用非標準calling convention,如果函式夠短也會把它內嵌省下呼叫的消耗。
所以我這裡函式能做成static的就寫成static,class也禁止使用private member function,幾乎都用.cpp裡的static函式代替(除非是寫在header裡inline的)。
第二個我也還沒有廣泛使用。SSE暫存器大小有128位元(16 byte),除了放單個浮點數以外也可以放向量,如果有個float[4]參數,以往通常的的做法是放在主記憶體裡再把它的指標傳入
float objRect[4]; float targetRect[4]; float v[2]; rectHitRect(objRect, v, targetRect); |
用上面那些calling convention把SSE暫存器搬出來用的話,將參數宣告成__m128型態,可以把這4個float塞在一個SSE暫存器裡。
#include<xmmintrin.h>
int rectHitRect(__m128 obj, __m128 v, __m128 target); …… __m128 objRect; __m128 targetRect; __m128 v; rectHitRect(objRect, v, targetRect); |
但是函式內外也要用SSE指令處理這個向量才能發揮威力,否則函式外多做了把純量包進SSE的工作,函式內再把向量拆成純量,反而比較慢。
還有C/C++ call by reference要用指標模擬,想在return值以外傳回其他東西還是要用指標。
綜合以上的方法,我給艾莉兒加了這一段,自動選擇該平台最快的calling convention
#ifdef _MSC_VER #if _MSC_VER>=1800 //VC 2013以後 #define REGPARM __vectorcall #else #define REGPARM __fastcall #endif #elif defined __GNUC__ #ifdef __i386 #define REGPARM __attribute((regparm(3),sseregparm)) #elif defined __amd64 #define REGPARM #endif #endif |
然後所有的函式宣告都加上REGPARM,不論獨立的函式或class member,用VC編譯時也加上/Gv。
int REGPARM rectHitRect(const float* obj, float* v, const float* target);
class TileMap{ int REGPARM load(const char* chunk, int size, int layer); int REGPARM hit(int shape, const float* obj, float v[2]) const; }; |
昨天一時興起做了另一個改造,把部分碰撞判定演算法改成用SSE來算,實測結果執行時間減少約25%。
因為即使寫了「v[0]=v[0]+obj[0]; v[1]=v[1]+obj[1]; v[2]=…………」,從產生的組合語言程式碼可以看出compiler也不會把它向量化,還是用純量指令一個一個算,compiler在這方面沒有很聰明,想用SSE的向量運算還是得人工寫指令。
雖然有多少實質效益很難說,但是向速度極限挑戰是很有趣的。
寫到這裡好奇一件事,現在一般人都是用64位元作業系統了,很多應用程式也有提供64位元的版本。
如果我這裡以後的作品都編譯成64位元,會不會有人的電腦不能執行?