前言
Go由于簡單易學(xué)、語言層面支持并發(fā)、容易部署等優(yōu)點(diǎn),越來越受到大家的歡迎 。但是由于某些原因,Go還沒有提供語言級的SIMD函數(shù),編譯優(yōu)化也沒有Clang等其他編譯器做得更深入 , 因此在某些考慮性能或成本的場景下,C/C++更具優(yōu)勢 。本人之前研究了字節(jié)的高性能庫sonic,借鑒其中使用C重寫熱點(diǎn)函數(shù)的思路 , 另外考慮直接調(diào)用用C重寫的函數(shù)的場景,給出使用C重寫Go中cpu密集型函數(shù)的一般方法 。
1 分析程序中是否存在cpu熱點(diǎn)
【【后臺技術(shù)】用C重寫Go中cpu密集型函數(shù)的一般方法】首先分析服務(wù)中cpu操作熱點(diǎn)分布,查看是否存在優(yōu)化的必要 。如果沒有明顯的cpu熱點(diǎn)函數(shù),則沒有必要引入本文的方法引入開發(fā)編譯的復(fù)雜度 。
1)使用工具分析
可以使用工具如pprof,Go的性能分析工具trace來分析cpu熱點(diǎn),相關(guān)的資料比較多 , 這里不再贅述 。
2)明顯的cpu密集操作
如果存在大數(shù)據(jù)量的向量操作考勤系統(tǒng)的c語言源代碼,則可以使用文中的方法優(yōu)化 。
2 使用C編寫熱點(diǎn)函數(shù)
為什么不使用cgo
調(diào)用C函數(shù)的時候,必須切換當(dāng)前的棧為線程的主棧,這帶來了兩個比較嚴(yán)重的問題:
線程的棧在Go運(yùn)行時是比較少的,受到P/M數(shù)量的限制 , 一般可以簡單的理解成受到限制; 由于需要同時保留C/C++的運(yùn)行時 , CGO需要在兩個運(yùn)行時和兩個ABI(抽象二進(jìn)制接口)之間做翻譯和協(xié)調(diào) 。這就帶來了很大的開銷 。
2.1 與C類型轉(zhuǎn)換
Go與C數(shù)據(jù)類型對照表
go類型
c類型
.
void *
int
type GoString struct {Ptr unsafe.PointerLen int}type GoSlice struct {Ptr unsafe.PointerLen intCap int}
2.2 一些高性能C代碼的方法
既然要用C重寫熱點(diǎn)函數(shù),則有必要給出一些寫出高性能C代碼的方法 ??紤]通用性,這里列出一些非業(yè)務(wù)邏輯、算法相關(guān)的幾種可以提高性能的方法 。
1)loop
loop 是一種減少循環(huán)退出判斷操作的方法,比如下面的代碼片段
int sum = 0;for (unsigned int i = 0; i < 100; i++) {sum += i;}
可以通過loop 方法修改為
int sum = 0;for (unsigned int i = 0; i < 100; i+=5) {sum += i;sum += i + 1;sum += i + 2;sum += i + 3;sum += i + 4;}
將i
缺點(diǎn):
loop 會導(dǎo)致代碼膨脹,從而增加內(nèi)存開銷,如果是服務(wù)端場景,增加的內(nèi)存開銷是微不足道的 。
2)SIMD
SIMD是Data的縮寫,即單指令流多數(shù)據(jù)流,同時對多個數(shù)據(jù)執(zhí)行相同的操作 。使用SIMD有幾種方法,比如使用Intel提供的封裝了SIMD的庫、借助編譯器自動向量化、有的編譯器(如Cilk)支持的編譯器指示符# simd強(qiáng)制將循環(huán)向量化、使用內(nèi)置函數(shù) 。
指令的示例如下 , 一次執(zhí)行8個float值的加法 。
intmain(){ __m128 v0 = _mm_set_ps(1.0f, 2.0f, 3.0f, 4.0f); __m128 v1 = _mm_set_ps(1.0f, 2.0f, 3.0f, 4.0f); __m128 result = _mm_add_ps(v0, v1);}
這里不展開幾種指令集下的函數(shù)列表和用法,詳見IntelGuide 。
3)減少cache miss
一起使用的函數(shù)聲明定義在一起; 一起使用的變量存儲在一起,通過結(jié)構(gòu)體的方式整理到一起,或者定義為局部變量 。變量盡可能的在靠近第一次使用的位置聲明和定義,即就近原則( of ) 。動態(tài)申請的變量是cache不友好的,如stl容器、,可以的話避免使用 。
4)減少函數(shù)調(diào)用開銷
小函數(shù)使用內(nèi)聯(lián)
使用迭代而不是遞歸
5)減少分支
使用計算減少分支
長的if else改成
出現(xiàn)概率更高條件放在前面
6)
這里指的是將cpu開銷較大的運(yùn)算修改為開銷較低的運(yùn)算,包括但不限于以下場景:
優(yōu)先使用位操作(位操作的性能高于加減乘除等操作); 優(yōu)先使用無符號數(shù)(無符號數(shù)的性能優(yōu)于有符號數(shù)); 盡量不要使用浮點(diǎn)數(shù)(浮點(diǎn)數(shù)),如通過舍棄不必要的精度、小數(shù)點(diǎn)后位數(shù)有限的值可以用整數(shù)保存等方法;
2.4 編譯
c語言編寫的函數(shù)編譯成Go可以調(diào)用的匯編語言 , 步驟如下圖:
2.4.1 編譯成x86匯編
使用Clang匯編
clang -S -DENABLE_AVX2 -target x86_64-unknown-none -masm=intel -mno-red-zone -mstackrealign -mllvm -inline-threshold=1000 -fno-asynchronous-unwind-tables -fno-exceptions -fno-rtti -O3 -fno-builtin -ffast-math -mavx add.c -o add.s
這里示例的參數(shù)為,即AVX2指令集 。編譯時需要編譯多次,生成每個指令集的匯編文件,Go程序啟動時根據(jù)指令集選擇使用的文件 。
2.4.2 轉(zhuǎn)化成plan9匯編
Go使用的匯編為plan9匯編,而clang編譯出來的為x86匯編,需要轉(zhuǎn)化為plan9匯編 。
本文在3和4分別給出直接調(diào)用和熱點(diǎn)函數(shù)組裝兩種調(diào)用方式:直接調(diào)用使用直接轉(zhuǎn)換的plan9匯編文件即可;組合調(diào)用的方式需要獲取每個熱點(diǎn)函數(shù)的地址 , 基于函數(shù)調(diào)用開銷考慮 , 參考字節(jié)的sonic使用另一個轉(zhuǎn)換工具 。
3 直接調(diào)用
直接調(diào)用C編譯出來的匯編代碼,需要先將x86匯編轉(zhuǎn)換為plan9匯編,然后使用樁函數(shù)調(diào)用即可 。
3.1 示例目錄結(jié)構(gòu)
可以參考下面的示例目錄結(jié)構(gòu)來組織代碼:
.├── go.mod├── go.sum├── lib│├── add_amd64.go // 樁函數(shù)定義,從native/add_amd64.go拷貝│└── add_amd64.s// plan9匯編代碼,從native/add_amd64.s拷貝├── main.go└── native├── add_amd64.go // 樁函數(shù)定義├── add_amd64.s// 輸出的plan9匯編文件├── add.c// C代碼源文件├── add.s// C編譯出來的X84匯編文件├── c2goasm// x86匯編轉(zhuǎn)plan9匯編工具├── asm2plan9s// c2goasm依賴的工具,用于生成Byte序列└── gen_asm.sh// 更新lib目錄下的樁函數(shù)和匯編代碼的腳本 , 包含編譯 , 匯編轉(zhuǎn)換,拷貝等操作
其中:為C文件、樁函數(shù)和轉(zhuǎn)換的工作目錄;lib為go程序運(yùn)行時使用的熱點(diǎn)函數(shù)目錄 。目錄內(nèi)各個文件的含義見上面的注釋 。
為依賴的庫,需要安裝并將安裝目錄添加到PATH環(huán)境變量中 。
3.2 定義樁函數(shù)
Go調(diào)用匯編需要定義與匯編函數(shù)定義相同的樁函數(shù) , 并使用指針類型的入?yún)鲄?。
例如如下C代碼:
void Add(int a, int b, int* result) {int sum = 0;sum = a + b;*result = sum;}
對應(yīng)的樁函數(shù)為:
//+build !noasm//+build !appenginepackage libimport "unsafe"http://go:noescape//go:nosplitfunc _Add(a, b int, result unsafe.Pointer)func Add(a, b int) int { var sum int _Add(a, b, unsafe.Pointer(&sum)) return sum}
其中,_Add為樁函數(shù)定義 。樁函數(shù)通過指針傳遞返回值,為了更方便調(diào)用 , 可以在封裝的函數(shù)Add時修改為通過返回值傳遞返回值 。
3.3 轉(zhuǎn)換成plan9匯編
使用將C語言直接編譯出來的x86匯編轉(zhuǎn)化為plan9匯編 。
./c2goasm -a add.s add_amd64.s
其中,示例文件add.s為x86匯編文件,.s為轉(zhuǎn)換后的plan9匯編文件 。需要注意的是,文件名后綴是必須的 。
3.4 拷貝到運(yùn)行時目錄
將目錄中生成的plan9匯編和樁函數(shù)拷貝到運(yùn)行目錄lib中cp * ../lib 。
4 組合調(diào)用
如果一次函數(shù)使用到多個熱點(diǎn)函數(shù),則需要將這些熱點(diǎn)函數(shù)組合起來 。
組合拼接的代碼是匯編指令 , 因此本章先介紹一些匯編的基本知識 , 然后介紹怎么將多個熱點(diǎn)函數(shù)拼接起來 。
需要說明的是:手寫匯編是非常不推薦的,原因是首先比較難寫,容易出錯,另外不能利用編譯器的優(yōu)化能力 , 寫出的代碼效率不一定最優(yōu) 。
4.0 go匯編簡介(plan9匯編)
入?yún)?br />
1.17版本之后函數(shù)調(diào)用是通過寄存器傳參的,按照參數(shù)的順序 , 分別賦值給AX、BX、CX、DI等寄存器 。文中后面的代碼以1.17以后得版本為例 。
匯編函數(shù)入?yún)?br />
熱點(diǎn)函數(shù)的入?yún)镈I、SI、DX等寄存器 。調(diào)用匯編函數(shù)之前需要將參數(shù)按照順序?qū)懭脒@幾個寄存器之中 。
這里需要注意的是,plan9匯編為-save , 如果中使用了當(dāng)前保存暫存結(jié)果寄存器 , 寄存器中的值需要保存到其他寄存器或者棧中 。
出參
出參寄存器為AX
4.1
self.Emit("SUBQ", arch.Imm(_FP_size), _SP)// SUBQ $_FP_size, SP self.Emit("MOVQ", _BP, arch.Ptr(_SP, _FP_offs)) // MOVQ BP, _FP_offs(SP) self.Emit("LEAQ", arch.Ptr(_SP, _FP_offs), _BP) // LEAQ _FP_offs(SP), BP self.Emit("MOVQ", _AX, _ARG_1)// MOVQ AX, rb+0(FP) self.Emit("MOVQ", _BX, _ARG_2)// MOVQ BX, vp+8(FP)
1) 壓棧
熱點(diǎn)函數(shù)在執(zhí)行時會產(chǎn)生中間結(jié)果,將這些中間結(jié)果保存在棧中 。需要在壓棧時為中間結(jié)果預(yù)留存儲空間 。函數(shù)的??臻g如下:
2)保存入?yún)?br />
在早期為了支持跨平臺,函數(shù)傳參是通過壓棧的方式,由于內(nèi)存訪問的速度慢于寄存器,這種傳參方式會帶來性能損耗 。1.17版本之后,傳參方式改為了寄存器傳參 。
對于1.17之后的版本 , 在調(diào)用熱點(diǎn)函數(shù)的過程中 , 這幾個寄存器會被復(fù)用 , 因此需要將入?yún)喝霔V斜4嫫饋?。
4.2
函數(shù)執(zhí)行完成的收尾工作:還原BP;釋放當(dāng)前函數(shù)的??臻g;返回 。
self.Emit("MOVQ", arch.Ptr(_SP, _FP_offs), _BP) // 還原BP指針 self.Emit("ADDQ", arch.Imm(_FP_size), _SP)// 釋放當(dāng)前函數(shù)的占空間 self.Emit("RET")// RET
4.3 熱點(diǎn)函數(shù)拼裝
熱點(diǎn)函數(shù)拼裝有幾個關(guān)鍵的地方:暫存中間結(jié)果;獲取下一個熱點(diǎn)函數(shù)地址;參數(shù)傳遞 。
暫存中間結(jié)果
plan9匯編需要調(diào)用者保存寄存器中的臨時寄存結(jié)果,即所謂的-save 。
中間結(jié)果可以保存在中不會使用到的寄存器中 , 但是為了防止誤用 , 可以將臨時結(jié)果保存在棧中 。調(diào)用入口函數(shù)時壓棧可以多壓一段內(nèi)存 , 在棧頂附近預(yù)留出來不,函數(shù)調(diào)用完成后再從內(nèi)存中加載到寄存器 。
獲取熱點(diǎn)函數(shù)的地址
使用匯編拼接熱點(diǎn)函數(shù)時,需要獲取熱點(diǎn)函數(shù)的地址,給出了一個方案:定義一個獲取參考地址的函數(shù),該函數(shù)返回自身的地址 , 并通過定義樁函數(shù)在Go代碼中直接調(diào)用;在轉(zhuǎn)換為plan9匯編時 , 計算每個熱點(diǎn)函數(shù)相對于參考地址的偏移量,然后通過()+獲取熱點(diǎn)函數(shù)的地址 。
由于獲取函數(shù)地址需要執(zhí)行一次函數(shù)調(diào)用,存在函數(shù)調(diào)用的開銷,而函數(shù)的地址是固定的 。因此可以在程序啟動時獲取一次地址記錄到全局變量中,后續(xù)如果還需要獲取函數(shù)的地址 , 直接讀取全局變量即可 。
字節(jié)的json庫sonic中的實(shí)現(xiàn)是將熱點(diǎn)函數(shù)的地址定義為由()+初始化的全局變量,這樣在程序運(yùn)行過程中,獲取每個熱點(diǎn)函數(shù)的地址只需要調(diào)用一次函數(shù) 。
//go:nosplit//go:noescape//goland:noinspection ALLfunc __native_entry__() uintptrvar (_subr__add= __native_entry__() + 32224_subr__f32toa= __native_entry__() + 28496_subr__f64toa= __native_entry__() + 752...)
我們可以進(jìn)一步優(yōu)化,定義一個全局變量,并用()初始化 , 熱點(diǎn)函數(shù)的地址定義為通過+初始化的全局變量,這樣在程序運(yùn)行過程中,只需要調(diào)用一次(),就可以獲取所有熱點(diǎn)函數(shù)的地址 。
//go:nosplit//go:noescape//goland:noinspection ALLfunc __native_entry__() uintptrvar (_native__entry= __native_entry__()_subr__add= _native__entry + 32224_subr__f32toa= _native__entry + 28496_subr__f64toa= _native__entry + 752...)
參數(shù)傳遞
若需向熱點(diǎn)函數(shù)傳遞參數(shù),可將參數(shù)按照順序賦值給DI、SI、DX等寄存器中 。例如 , 向Add函數(shù)傳遞兩個參數(shù):
self.Emit("MOVQ", _ARG_1, _DI)// MOVQ AX, rb+0(FP) self.Emit("MOVQ", _ARG_2, _SI)// MOVQ BX, vp+8(FP) self.call(native.FuncAdd)
其中:
、為暫存在棧或者寄存器中的參數(shù);_DI、_SI為DI和SI寄存器 。
Emit、call為自行封裝的函數(shù),將參數(shù)轉(zhuǎn)換為-asm中的數(shù)據(jù)結(jié)構(gòu)(4.4會介紹) 。
返回值保存在AX寄存器中 。
4.4 在線匯編
在線匯編使用從Go的匯編代碼中拷貝出來的庫-asm 。
一條匯編語句用obj.Prog結(jié)構(gòu)體表示,包含指令和參數(shù)數(shù)據(jù) 。參數(shù)均需轉(zhuǎn)化為obj.Addr結(jié)構(gòu),例如立即數(shù)表示為:
obj.Addr{Type:obj.TYPE_CONST,Offset: imm, }
Go匯編代碼庫中設(shè)置架構(gòu)即可獲取架構(gòu)對應(yīng)的指令和寄存器列表 , 如:
_AC = archassem.Set("amd64")
主要用于校驗(yàn)當(dāng)前指令是否在架構(gòu)中支持,若不支持可輸出錯誤提示或直接panic 。
每條匯編語句對應(yīng)的數(shù)據(jù)結(jié)構(gòu)obj Prog會被保存在一個鏈表中,然后將這個鏈表中的語句匯編 。
4.5 減少在線匯編的開銷
在線匯編存在開銷,而大多數(shù)場景下,熱點(diǎn)函數(shù)的組合是可重用的,即匯編結(jié)果是可重用的 ??梢允褂镁彺婊蛘唠x線編譯兩種方法來減少在線匯編的次數(shù) 。
4.5.1 緩存
對于可復(fù)用的匯編結(jié)果,緩存是一個比較容易想到的優(yōu)化方法 。
若熱點(diǎn)函數(shù)的組合不確定 , 類似sonic這種通用的json庫,可以參考其中的JIT(Just In Time)方案,即僅在需要時才執(zhí)行開銷巨大的匯編操作;并且將匯編結(jié)果緩存起來 , 再次需要時復(fù)用緩存的結(jié)果 。緩存的結(jié)構(gòu)體設(shè)計可參考sonic中的數(shù)組+hash 。
若熱點(diǎn)函數(shù)的組合數(shù)可控或基本確定,則可以使用更輕量級的實(shí)現(xiàn),比如定義一個數(shù)組來保存各個組合對應(yīng)的機(jī)器碼的指針地址 。
4.5.2 離線匯編
針對熱點(diǎn)函數(shù)組合確定的場景,也可以更進(jìn)一步優(yōu)化,可以離線完成匯編操作,然后將機(jī)器碼保存在文件中,或以常量的形式保存在二進(jìn)制文件中考勤系統(tǒng)的c語言源代碼,在服務(wù)運(yùn)行時直接加載到內(nèi)存執(zhí)行 。例如:
var loader loader.Loader loader = []byte{72,129,236,136,0,0,0,72,137,172,36,128,0,0,0,72,141,172,36,128,0,0,0,72,137,132,36,144,0,0,0,72,137,156,36,152,0,0,0,69,49,228,69,49,237,69,49,219,72,139,132,36,144,0,0,0,72,139,156,36,152,0,0,0,72,1,216,72,131,192,100,72,137,132,36,152,0,0,0,72,139,172,36,128,0,0,0,72,129,196,136,0,0,0,195}f := loader.Load("code", 1, 0) f1 := *(*funcs.SumFunc)(unsafe.Pointer(&f))
其中,Load函數(shù)的實(shí)現(xiàn)為將[]byte加載到堆中,并將對應(yīng)的地址空間權(quán)限設(shè)置為可運(yùn)行:
func (self Loader) LoadWithFaker(fn string, fp int, args int, faker interface{}) (f Function) { p := os.Getpagesize() n := (((len(self) - 1) / p) + 1) * p /* register the function */ m := mmap(n) /* reference as a slice */ s := *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{Data: m,Cap:n,Len:len(self), })) fmt.Println("fn:", fn, "; s:", self) /* copy the machine code, and make it executable */ copy(s, self) mprotect(m, n) return Function(&m)}
5 總結(jié)
本文考慮Go語言優(yōu)化不足、不能使用SIMD指令的現(xiàn)狀,為進(jìn)一步優(yōu)化性能,給出用C重寫Go中的cpu密集型函數(shù)的一般方法 。分別針對直接整個函數(shù)用C重寫、動態(tài)組裝熱點(diǎn)函數(shù)兩個場景 , 給出了重寫的實(shí)現(xiàn)和代碼示例 。當(dāng)go服務(wù)存在顯著cpu瓶頸時,可以考慮使用本文中的方法優(yōu)化 。
本文到此結(jié)束,希望對大家有所幫助 。
- C語言/C++編程,煙花表白程序
- ?為什么體操服上是國徽
- ?萬達(dá)imax3d會發(fā)眼鏡嗎 會,一般的3d影院都會配發(fā)3d眼睛。3d眼鏡大多數(shù)采用了
- ?甜玉米煮多久
- ?廖野天中國好聲音名次廖野天趙家豪battle誰晉級了
- ?越南為什么不參加冬奧會
- ?元宵節(jié)的月亮是圓的嗎
- ?魚子醬罐頭能直接吃嗎
- ?油膩大叔的標(biāo)配是什么
- 低gl是什么意思啊
