[golang]golang 彙編
- 2019 年 10 月 8 日
- 筆記
在某些場景下,我們需要進行一些特殊優化,因此我們可能需要用到golang彙編,golang彙編源於plan9,此方面的 介紹很多,就不進行展開了。我們WHY和HOW開始講起。
golang彙編相關的內容還是很少的,而且多數都語焉不詳,而且缺乏細節。對於之前沒有彙編經驗的人來說,是很難 理解的。而且很多資料都過時了,包括官方文檔的一些細節也未及時更新。因此需要掌握該知識的人需要仔細揣摩, 反覆實驗。
WHY
我們為什麼需要用到golang的彙編,基本出於以下場景。
- 演算法加速,golang編譯器生成的機器碼基本上都是通用程式碼,而且 優化程度一般,遠比不上C/C++的
gcc/clang
生成的優化程度高,畢竟時間沉澱在那裡。因此通常我們需要用到特 殊優化邏輯、特殊的CPU指令讓我們的演算法運行速度更快,如sse4_2/avx/avx2/avx-512
等。 - 擺脫golang編譯器的一些約束,如通過彙編調用其他package的私有函數。
- 進行一些hack的事,如通過彙編適配其他語言的ABI來直接調用其他語言的函數。
- 利用
//go:noescape
進行記憶體分配優化,golang編譯器擁有逃逸分析,用於決定每一個變數是分配在堆記憶體上 還是函數棧上。但是有時逃逸分析的結果並不是總讓人滿意,一些變數完全可以分配在函數棧上,但是逃逸分析將其 移動到堆上,因此我們需要使用golang編譯器的go:noescape
將其轉換,強制分配在函數棧上。當然也可以強制讓對象分配在堆上,可以參見這段實現。
HOW
使用到golang會彙編時,golang的對象類型、buildin對象、語法糖還有一些特殊機制就都不見了,全部底層實現 暴露在我們面前,就像你拆開一台電腦,暴露在你面前的是一堆PCB、電阻、電容等元器件。因此我們必須掌握一些 go ABI的機制才能進行golang彙編編程。
go彙編簡介
這部分內容可以參考:
暫存器
go 彙編中有4個核心的偽暫存器,這4個暫存器是編譯器用來維護上下文、特殊標識等作用的:
- FP(Frame pointer): arguments and locals
- PC(Program counter): jumps and branches
- SB(Static base pointer): global symbols
- SP(Stack pointer): top of stack
所有用戶空間的數據都可以通過FP(局部數據、輸入參數、返回值)或SB(全局數據)訪問。 通常情況下,不會對SB
/FP
暫存器進行運算操作,通常情況以會以SB
/FP
作為基準地址,進行偏移解引用 等操作。
SB
而且在某些情況下SB
更像一些聲明標識,其標識語句的作用。例如:
TEXT runtime·_divu(SB), NOSPLIT, $16-0
在這種情況下,TEXT
、·
、SB
共同作用聲明了一個函數runtime._divu
,這種情況下,不能對SB
進行解引用。GLOBL fast_udiv_tab<>(SB), RODATA, $64
在這種情況下,GLOBL
、fast_udiv_tab
、SB
共同作用, 在RODATA段聲明了一個私有全局變數fast_udiv_tab
,大小為64byte,此時可以對SB
進行偏移、解引用。CALL runtime·callbackasm1(SB)
在這種情況下,CALL
、runtime·callbackasm1
、SB
共同標識, 標識調用了一個函數runtime·callbackasm1
。MOVW $fast_udiv_tab<>-64(SB), RM
在這種情況下,與2類似,但不是聲明,是解引用全局變數fast_udiv_tab
。
FB
FP
偽暫存器用來標識函數參數、返回值。其通過symbol+offset(FP)
的方式進行使用。例如arg0+0(FP)
表示第函數第一個參數其實的位置(amd64平台),arg1+8(FP)
表示函數參數偏移8byte的另一個參數。arg0
/arg1
用於助記,但是必須存在,否則 無法通過編譯。至於這兩個參數是輸入參數還是返回值,得對應其函數聲明的函數個數、位置才能知道。 如果操作命令是MOVQ arg+8(FP), AX
的話,MOVQ
表示對8byte長的記憶體進行移動,其實位置是函數參數偏移8byte 的位置,目的是暫存器AX
,因此此命令為將一個參數賦值給暫存器AX
,參數長度是8byte,可能是一個uint64,FP
前面的arg+
是標記。至於FP
的偏移怎麼計算,會在後面的go函數調用中進行表述。同時我們 還可以在命令中對FP
的解引用進行標記,例如first_arg+0(FP)
將FP
的起始標記為參數first_arg
,但是 first_arg
只是一個標記,在彙編中first_arg
是不存在的。
PC
實際上就是在體系結構的知識中常見的pc
暫存器,在x86平台下對應ip
暫存器,amd64上則是rip
。除了個別跳轉 之外,手寫程式碼與PC
暫存器打交道的情況較少。
SP
SP
是棧指針暫存器,指向當前函數棧的棧頂,通過symbol+offset(SP)
的方式使用。offset 的合法取值是 [-framesize, 0)
,注意是個左閉右開的區間。假如局部變數都是8位元組,那麼第一個局部變數就可以用localvar0-8(SP)
來表示。
但是硬體暫存器中也有一個SP
。在用戶手寫的彙編程式碼中,如果操作SP
暫存器時沒有帶symbol
前綴,則操作的是 硬體暫存器SP
。在實際情況中硬體暫存器SP
與偽暫存器SP
並不指向同一地址,具體硬體暫存器SP
指向哪裡與函 數
但是:
對於編譯輸出(go tool compile -S / go tool objdump
)的程式碼來講,目前所有的SP
都是硬體暫存器SP
,無論 是否帶 symbol。
我們這裡對容易混淆的幾點簡單進行說明:
- 偽
SP
和硬體SP
不是一回事,在手寫程式碼時,偽SP
和硬體SP
的區分方法是看該SP
前是否有symbol
。如果有symbol
,那麼即為偽暫存器,如果沒有,那麼說明是硬體SP
暫存器。 - 偽
SP
和FP
的相對位置是會變的,所以不應該嘗試用偽SP
暫存器去找那些用FP
+offset來引用的值,例如函數的 入參和返回值。 - 官方文檔中說的偽
SP
指向stack的top,是有問題的。其指向的局部變數位置實際上是整個棧的棧底(除caller BP 之外),所以說bottom更合適一些。 - 在
go tool objdump/go tool compile -S
輸出的程式碼中,是沒有偽SP
和FP
暫存器的,我們上面說的區分偽SP
和硬體SP
暫存器的方法,對於上述兩個命令的輸出結果是沒法使用的。在編譯和反彙編的結果中,只有真實的SP
寄 存器。 FP
和Go的官方源程式碼里的framepointer
不是一回事,源程式碼里的framepointer
指的是caller BP暫存器的值, 在這裡和caller的偽SP
是值是相等的。
注: 如何理解偽暫存器FP
和SP
呢?其實偽暫存器FP
和SP
相當於plan9偽彙編中的一個助記符,他們是根據當前函數棧空間計算出來的一個相對於物理暫存器SP
的一個偏移量坐標。當在一個函數中,如果用戶手動修改了物理暫存器SP
的偏移,則偽暫存器FP
和SP
也隨之發生對應的偏移。例如
// func checking()(before uintptr, after uintptr) TEXT ·checking(SB),$4112-16 LEAQ x-0(SP), DI // MOVQ DI, before+0(FP) // 將原偽暫存器SP偏移量存入返回值before MOVQ SP, BP // 存儲物理SP偏移量到BP暫存器 ADDQ $4096, SP // 將物理SP偏移增加4K LEAQ x-0(SP), SI MOVQ BP, SP // 恢復物理SP,因為修改物理SP後,偽暫存器FP/SP隨之改變, // 為了正確訪問FP,先恢復物理SP MOVQ SI, cpu+8(FP) // 將偏移後的偽暫存器SP偏移量存入返回值after RET // 從輸出的after-before來看,正好相差4K
通用暫存器
在plan9彙編里還可以直接使用的amd64的通用暫存器,應用程式碼層面會用到的通用暫存器主要是: rax, rbx, rcx, rdx, rdi, rsi, r8~r15
這14個暫存器,雖然rbp
和rsp
也可以用,不過bp
和sp
會被用來管 理棧頂和棧底,最好不要拿來進行運算。plan9中使用暫存器不需要帶r
或e
的前綴,例如rax
,只要寫AX
即可:
MOVQ $101, AX = mov rax, 101
下面是通用通用暫存器的名字在 IA64 和 plan9 中的對應關係:
X86_64 |
rax |
rbx |
rcx |
rdx |
rdi |
rsi |
rbp |
rsp |
r8 |
r9 |
r10 |
r11 |
r12 |
r13 |
r14 |
rip |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Plan9 |
AX |
BX |
CX |
DX |
DI |
SI |
BP |
SP |
R8 |
R9 |
R10 |
R11 |
R12 |
R13 |
R14 |
PC |
控制流
對於函數控制流的跳轉,是用label來實現的,label只在函數內可見,類似goto
語句:
next: MOVW $0, R1 JMP next
指令
使用彙編就意味著喪失了跨平台的特性。因此使用對應平台的彙編指令。這個需要自行去了解,也可以參考GoFunctionsInAssembly 其中有各個平台彙編指令速覽和對照。
文件命名
使用到彙編時,即表明了所寫的程式碼不能夠跨平台使用,因此需要針對不同的平台使用不同的彙編 程式碼。go編譯器採用文件名中加入平台名後綴進行區分。
比如sqrt_386.s sqrt_amd64p32.s sqrt_amd64.s sqrt_arm.s
或者使用+build tag
也可以,詳情可以參考go/build。
函數聲明
首先我們先需要對go彙編程式碼有一個抽象的認識,因此我們可以先看一段go彙編程式碼:
TEXT runtime·profileloop(SB),NOSPLIT,$8-16 MOVQ $runtime·profileloop1(SB), CX MOVQ CX, 0(SP) CALL runtime·externalthreadhandler(SB) RET
此處聲明了一個函數profileloop
,函數的聲明以TEXT
標識開頭,以${package}·${function}
為函數名。 如何函數屬於本package時,通常可以不寫${package}
,只留·${function}
即可。·
在mac上可以用shift+option+9
打出。$8
表示該函數棧大小為8byte,計算棧大小時,需要考慮局部變數和本函數內調用其他函數時,需要傳參的空間,不含函數返回地址和CALLER BP
(這2個後面會講到)。 $16
表示該函數入參和返回值一共有16byte。當有NOSPLIT
標識時,可以不寫輸入參數、返回值佔用的大小。
那我們再看一個函數:
TEXT ·add(SB),$0-24 MOVQ x+0(FP), BX MOVQ y+8(FP), BP ADDQ BP, BX MOVQ BX, ret+16(FP) RET
該函數等同於:
func add(x, y int64) int64 { return x + y }
該函數沒有局部變數,故$
後第一個數為0,但其有2個輸入參數,1個返回值,每個值佔8byte,則第二個數為24(3*8byte)。
全局變數聲明
以下就是一個私有全局變數的聲明,<>
表示該變數只在該文件內全局可見。 全局變數的數據部分採用DATA symbol+offset(SB)/width, value
格式進行聲明。
DATA divtab<>+0x00(SB)/4, $0xf4f8fcff // divtab的前4個byte為0xf4f8fcff DATA divtab<>+0x04(SB)/4, $0xe6eaedf0 // divtab的4-7個byte為0xe6eaedf0 ... DATA divtab<>+0x3c(SB)/4, $0x81828384 // divtab的最後4個byte為0x81828384 GLOBL divtab<>(SB), RODATA, $64 // 全局變數名聲明,以及數據所在的段"RODATA",數據的長度64byte
GLOBL runtime·tlsoffset(SB), NOPTR, $4 // 聲明一個全局變數tlsoffset,4byte,沒有DATA部分,因其值為0。 // NOPTR 表示這個變數數據中不存在指針,GC不需要掃描。
類似RODATA
/NOPTR
的特殊聲明還有:
- NOPROF = 1 (For TEXT items.) Don』t profile the marked function. This flag is deprecated.
- DUPOK = 2 It is legal to have multiple instances of this symbol in a single binary. The linker will choose one of the duplicates to use.
- NOSPLIT = 4 (For TEXT items.) Don』t insert the preamble to check if the stack must be split. The frame for the routine, plus anything it calls, must fit in the spare space at the top of the stack segment. Used to protect routines such as the stack splitting code itself.
- RODATA = 8 (For DATA and GLOBL items.) Put this data in a read-only section.
- NOPTR = 16 (For DATA and GLOBL items.) This data contains no pointers and therefore does not need to be scanned by the garbage collector.
- WRAPPER = 32 (For TEXT items.) This is a wrapper function and should not count as disabling recover.
- NEEDCTXT = 64 (For TEXT items.) This function is a closure so it uses its incoming context register.
局部變數聲明
局部變數存儲在函數棧上,因此不需要額外進行聲明,在函數棧上預留出空間,使用命令操作這些記憶體即可。因此這些 局部變數沒有標識,操作時,牢記局部變數的分布、記憶體偏移即可。
宏
在彙編文件中可以定義、引用宏。通過#define get_tls(r) MOVQ TLS, r
類似語句來定義一個宏,語法結構與C語言類似;通過#include "textflag.h"
類似語句來引用一個外部宏定義文件。
go編譯器為了方便彙編中訪問struct
的指定欄位,會在編譯過程中自動生成一個go_asm.h
文件,可以通過#include "go_asm.h"
語言來引用,該文件中會生成該包內全部struct
的每個欄位的偏移量宏定義與結構體大小的宏定義,比如:
type vdsoVersionKey struct { version string verHash uint32 }
會生成宏定義:
#define vdsoVersionKey__size 24 #define vdsoVersionKey_version 0 #define vdsoVersionKey_verHash 16
在彙編程式碼中,我們就可以直接使用這些宏:
MOVQ vdsoVersionKey_version(DX) AX MOVQ (vdsoVersionKey_version+vdsoVersionKey_verHash)(DX) AX
比如我們在runtime
包中經常會看見一些程式碼就是如此:
MOVQ DX, m_vdsoPC(BX) LEAQ ret+0(SP), DX MOVQ DX, m_vdsoSP(BX)
我們可以通過命令go tool compile -S -asmhdr dump.h *.go
來導出相關文件編譯過程中會生成的宏定義。
地址運算
欄位部分引用自《plan9-assembly-完全解析》:
地址運算也是用 lea 指令,英文原意為
Load Effective Address
,amd64 平台地址都是8
個位元組,所以直接就用LEAQ
就好:
LEAQ (BX)(AX*8), CX // 上面程式碼中的 8 代表 scale // scale 只能是 0、2、4、8 // 如果寫成其它值: // LEAQ (BX)(AX*3), CX // ./a.s:6: bad scale: 3 // 整個表達式含義是 CX = BX + (AX * 8) // 如果要表示3倍的乘法可以表示為: LEAQ (AX)(AX*2), CX // => CX = AX + (AX * 2) = AX * 3 // 用 LEAQ 的話,即使是兩個暫存器值直接相加,也必須提供 scale // 下面這樣是不行的 // LEAQ (BX)(AX), CX // asm: asmidx: bad address 0/2064/2067 // 正確的寫法是 LEAQ (BX)(AX*1), CX // 在暫存器運算的基礎上,可以加上額外的 offset LEAQ 16(BX)(AX*1), CX // 整個表達式含義是 CX = 16 + BX + (AX * 8) // 三個暫存器做運算,還是別想了 // LEAQ DX(BX)(AX*8), CX // ./a.s:13: expected end of operand, found (
其餘MOVQ
等表達式的區別是,在暫存器加偏移的情況下MOVQ
會對地址進行解引用:
MOVQ (AX), BX // => BX = *AX 將AX指向的記憶體區域8byte賦值給BX MOVQ 16(AX), BX // => BX = *(AX + 16) MOVQ AX, BX // => BX = AX 將AX中存儲的內容賦值給BX,注意區別
buildin類型
在golang彙編中,沒有struct/slice/string/map/chan/interface{}
等類型,有的只是暫存器、記憶體。因此我們需要了解這些 類型對象在彙編中是如何表達的。
(u)int??/float??
uint32
就是32bit長的一段記憶體,float64
就是64bit長的一段記憶體,其他相似類型可以以此類推。
int/unsafe.Pointer/unint
在32bit系統中int
等同於int32
,uintptr
等同於uint32
,unsafe.Pointer
長度32bit。
在64bit系統中int
等同於int64
,uintptr
等同於uint64
,unsafe.Pointer
長度64bit。
byte
等同於uint8
。rune
等同於int32
。
string
底層是StringHeader
這樣一個結構體,slice
底層是SliceHeader
這樣一個結構體。
map
map
是指向hmap
的一個unsafe.Pointer
chan
chan
是指向hchan
的一個unsafe.Pointer
interface{}
interface{}
是eface
這樣一個結構體。詳細可以參考深入解析GO
go函數調用
通常函數會有輸入輸出,我們要進行編程就需要掌握其ABI,了解其如何傳遞輸入參數、返回值、調用函數。
go彙編使用的是caller-save
模式,因此被調用函數的參數、返回值、棧位置都需要由調用者維護、準備。因此 當你需要調用一個函數時,需要先將這些工作準備好,方能調用下一個函數,另外這些都需要進行記憶體對其,對其 的大小是sizeof(uintptr)
。
我們將結合一些函數來進行說明:
無局部變數的函數
注意:其實go函數的棧布局在是否有局部變數時,是沒有區別的。在沒有局部變數時,只是少了局部變數那部分空間。在當時研究的時候,未能抽象其共同部分,導致拆成2部分寫了。
對於手寫彙編來說,所有參數通過棧來傳遞,通過偽暫存器FP
偏移進行訪問。函數的返回值跟隨在輸入參數 後面,並且對其到指針大小。amd64平台上指針大小為8byte。如果輸入參數為20byte。則返回值會在從24byte其, 中間跳過4byte用以對其。
func xxx(a, b, c int) (e, f, g int) { e, f, g = a, b, c return }
該函數有3個輸入參數、3個返回值,假設我們使用x86_64平台,因此一個int佔用8byte。則其函數棧空間為:
高地址位 ┼───────────┼ │ 返回值g │ ┼───────────┼ │ 返回值f │ ┼───────────┼ │ 返回值e │ ┼───────────┼ │ 參數之c │ ┼───────────┼ │ 參數之b │ ┼───────────┼ │ 參數之a │ ┼───────────┼ <-- 偽FP │ 函數返回地址│ ┼───────────┼ <-- 偽SP 和 硬體SP 低地址位
各個輸入參數和返回值將以倒序的方式從高地址位分布於棧空間上,由於沒有局部變數,則xxx的函數棧空間為 0,根據前面的描述,該函數應該為:
#include "textflag.h" TEXT ·xxx(SB),NOSPLIT,$0-48 MOVQ a+0(FP), AX // FP+0 為參數a,將其值拷貝到暫存器AX中 MOVQ AX, e+24(FP) // FP+24 為返回值e,將暫存器AX賦值給返回值e MOVQ b+8(FP), AX // FP+8 為參數b MOVQ AX, f+32(FP) // FP+24 為返回值f MOVQ c+16(FP), AX // FP+16 為參數c MOVQ AX, g+40(FP) // FP+24 為返回值g RET // return
然後在一個go源文件(.go)中聲明該函數即可
func xxx(a, b, c int) (e, f, g int)
有局部變數的函數
當函數中有局部變數時,函數的棧空間就應該留出足夠的空間:
func zzz(a, b, c int) [3]int{ var d [3]int d[0], d[1], d[2] = a, b, c return d }
當函數中有局部變數時,我們就需要移動函數棧幀來進行棧記憶體分配,因此我們就需要了解相關平台電腦體系 的一些設計問題,在此我們只講解x86平台的相關要求,我們先需要參考:
其中講到x86平台上BP
暫存器,通常用來指示函數棧的起始位置,僅僅其一個指示作用,現代編譯器生成的程式碼 通常不會用到BP
暫存器,但是可能某些debug工具會用到該暫存器來尋找函數參數、局部變數等。因此我們寫彙編 程式碼時,也最好將棧起始位置存儲在BP
暫存器中。因此在amd64平台上,會在函數返回值之後插入8byte來放置CALLER BP
暫存器。
此外需要注意的是,CALLER BP
是在編譯期由編譯器插入的,用戶手寫程式碼時,計算framesize
時是不包括這個 CALLER BP
部分的,但是要計算函數返回值的8byte。是否插入CALLER BP
的主要判斷依據是:
- 函數的棧幀大小大於
0
- 下述函數返回
true
func Framepointer_enabled(goos, goarch string) bool { return framepointer_enabled != 0 && goarch == "amd64" && goos != "nacl" }
此處需要注意,go編譯器會將函數棧空間自動加8,用於存儲BP暫存器,跳過這8位元組後才是函數棧上局部變數的記憶體。 邏輯上的FP/SP位置就是我們在寫彙編程式碼時,計算偏移量時,FP/SP的基準位置,因此局部變數的記憶體在邏輯SP的低地 址側,因此我們訪問時,需要向負方向偏移。
實際上,在該函數被調用後,編譯器會添加SUBQ
/LEAQ
程式碼修改物理SP指向的位置。我們在反彙編的程式碼中能看到這部分操作,因此我們需要注意物理SP與偽SP指向位置的差別。
高地址位 ┼───────────┼ │ 返回值g │ ┼───────────┼ │ 返回值f │ ┼───────────┼ │ 返回值e │ ┼───────────┼ │ 參數之c │ ┼───────────┼ │ 參數之b │ ┼───────────┼ │ 參數之a │ ┼───────────┼ <-- 偽FP │ 函數返回地址│ ┼───────────┼ │ CALLER BP │ ┼───────────┼ <-- 偽SP │ 變數之[2] │ <-- d0-8(SP) ┼───────────┼ │ 變數之[1] │ <-- d1-16(SP) ┼───────────┼ │ 變數之[0] │ <-- d2-24(SP) ┼───────────┼ <-- 硬體SP 低地址位
圖中的函數返回地址
使用的是調用者的棧空間,CALLER BP
由編輯器「透明」插入,因此,不算在當前函數的棧空間內。我們實現該函數的彙編程式碼:
#include "textflag.h" TEXT ·zzz(SB),NOSPLIT,$24-48 // $24值棧空間24byte,- 後面的48跟上面的含義一樣, // 在編譯後,棧空間會被+8用於存儲BP暫存器,這步驟由編譯器自動添加 MOVQ $0, d-24(SP) // 初始化d[0] MOVQ $0, d-16(SP) // 初始化d[1] MOVQ $0, d-8(SP) // 初始化d[2] MOVQ a+0(FP), AX // d[0] = a MOVQ AX, d-24(SP) // MOVQ b+8(FP), AX // d[1] = b MOVQ AX, d-16(SP) // MOVQ c+16(FP), AX // d[2] = c MOVQ AX, d-8(SP) // MOVQ d-24(SP), AX // d[0] = return [0] MOVQ AX, r+24(FP) // MOVQ d-16(SP), AX // d[1] = return [1] MOVQ AX, r+32(FP) // MOVQ d-8(SP), AX // d[2] = return [2] MOVQ AX, r+40(FP) // RET // return
然後我們go源碼中聲明該函數:
func zzz(a, b, c int) [3]int
彙編中調用其他函數
在彙編中調用其他函數通常可以使用2中方式:
JMP
含義為跳轉,直接跳轉時,與函數棧空間相關的幾個暫存器SP
/FP
不會發生變化,可以理解為被調用函數 復用調用者的棧空間,此時,參數傳遞採用暫存器傳遞,調用者和被調用者協商好使用那些寄存傳遞參數,調用者將 參數寫入這些暫存器,然後跳轉到被調用者,被調用者從相關暫存器讀出參數。具體實踐可以參考1。CALL
通過CALL
命令來調用其他函數時,棧空間會發生響應的變化(暫存器SP
/FP
隨之發生變化),傳遞參數時,我們需要輸入參數、返回值按之前將的棧布局安排在調用者的棧頂(低地址段),然後再調用CALL
命令來調用其函數,調用CALL
命令後,SP
暫存器會下移一個WORD
(x86_64上是8byte),然後進入新函數的棧空間運行。下圖中return addr(函數返回地址)
不需要用戶手動維護,CALL
指令會自動維護。
下面演示一個CALL
方法調用的例子:
func yyy(a, b, c int) [3]int { return zzz(a, b, c) }
該函數使用彙編實現就是:
TEXT ·yyy0(SB), $48-48 MOVQ a+0(FP), AX MOVQ AX, ia-48(SP) MOVQ b+8(FP), AX MOVQ AX, ib-40(SP) MOVQ c+16(FP), AX MOVQ AX, ic-32(SP) CALL ·zzz(SB) MOVQ z2-24(SP), AX MOVQ AX, r2+24(FP) MOVQ z1-16(SP), AX MOVQ AX, r1+32(FP) MOVQ z1-8(SP), AX MOVQ AX, r2+40(FP) RET
然後在go文件中聲明yyy0
,並且在main
函數中調用:
func yyy0(a, b, c int) [3]int //go:noinline func yyy1(a, b, c int) [3]int { return zzz(a, b, c) } func main() { y0 := yyy0(1, 2, 3) y1 := yyy1(1, 2, 3) println("yyy0", y0[0], y0[1], y0[2]) println("yyy1", y1[0], y1[1], y1[2]) }
在函數yyy0
的棧空間分布為:
高地址位 ┼───────────┼ │ 返回值[2] │ <-- r2+40(FP) ┼───────────┼ │ 返回值[1] │ <-- r1+32(FP) ┼───────────┼ │ 返回值[0] │ <-- r2+24(FP) ┼───────────┼ │ 參數之c │ <-- c+16(FP) ┼───────────┼ │ 參數之b │ <-- b+8(FP) ┼───────────┼ │ 參數之a │ <-- a+0(FP) ┼───────────┼ <-- 偽FP │ 函數返回地址│ <-- yyy0函數返回值 ┼───────────┼ │ CALLER BP │ ┼───────────┼ <-- 偽SP │ 返回值[2] │ <-- z1-8(SP) ┼───────────┼ │ 返回值[1] │ <-- z1-16(SP) ┼───────────┼ │ 返回值[0] │ <-- z2-24(SP) ┼───────────┼ │ 參數之c │ <-- ic-32(SP) ┼───────────┼ │ 參數之b │ <-- ib-40(SP) ┼───────────┼ │ 參數之a │ <-- ia-48(SP) ┼───────────┼ <-- 硬體SP 低地址位
其調用者和被調用者的棧關係為(該圖來自plan9 assembly 完全解析):
caller +------------------+ | | +----------------------> -------------------- | | | | | caller parent BP | | BP(pseudo SP) -------------------- | | | | | Local Var0 | | -------------------- | | | | | ....... | | -------------------- | | | | | Local VarN | -------------------- caller stack frame | | | callee arg2 | | |------------------| | | | | | callee arg1 | | |------------------| | | | | | callee arg0 | | SP(Real Register) ----------------------------------------------+ FP(virtual register) | | | | | | return addr | parent return address | +----------------------> +------------------+--------------------------- <-------------------------------+ | caller BP | | | (caller frame pointer) | | BP(pseudo SP) ---------------------------- | | | | | Local Var0 | | ---------------------------- | | | | Local Var1 | ---------------------------- callee stack frame | | | ..... | ---------------------------- | | | | | Local VarN | | SP(Real Register) ---------------------------- | | | | | | | | | | | | | | | | +--------------------------+ <-------------------------------+ callee
此外我們還可以做一些優化,其中中間的臨時變數,讓zzz
的輸入參數、返回值復用yyy
的輸入參數、返回值 這部分空間,其程式碼為:
TEXT ·yyy(SB),NOSPLIT,$0-48 MOVQ pc+0(SP), AX // 將PC暫存器中的值暫時保存在最後一個返回值的位置,因為在 // 調用zzz時,該位置不會參與計算 MOVQ AX, ret_2+40(FP) // MOVQ a+0(FP), AX // 將輸入參數a,放置在棧頂 MOVQ AX, z_a+0(SP) // MOVQ b+8(FP), AX // 將輸入參數b,放置在棧頂+8 MOVQ AX, z_b+8(SP) // MOVQ c+16(FP), AX // 將輸入參數c,放置在棧頂+16 MOVQ AX, z_c+16(SP) // CALL ·zzz(SB) // 調用函數zzz MOVQ ret_2+40(FP), AX // 將PC暫存器恢復 MOVQ AX, pc+0(SP) // MOVQ z_ret_2+40(SP), AX // 將zzz的返回值[2]防止在yyy返回值[2]的位置 MOVQ AX, ret_2+40(FP) // MOVQ z_ret_1+32(SP), AX // 將zzz的返回值[1]防止在yyy返回值[1]的位置 MOVQ AX, ret_1+32(FP) // MOVQ z_ret_0+24(SP), AX // 將zzz的返回值[0]防止在yyy返回值[0]的位置 MOVQ AX, ret_0+24(FP) // RET // return
整個函數調用過程為:
高地址位 ┼───────────┼ ┼────────────┼ ┼────────────┼ │ 返回值[2] │ │ 函數返回值 │ │ PC │ ┼───────────┼ ┼────────────┼ ┼────────────┼ │ 返回值[1] │ │zzz返回值[2] │ │zzz返回值[2] │ ┼───────────┼ ┼────────────┼ ┼────────────┼ │ 返回值[0] │ │zzz返回值[1] │ │zzz返回值[1] │ ┼───────────┼ =調整後=> ┼────────────┼ =調用後=> ┼────────────┼ │ 參數之c │ │zzz返回值[0] │ │zzz返回值[0] │ ┼───────────┼ ┼────────────┼ ┼────────────┼ │ 參數之b │ │ 參數之c │ │ 參數之c │ ┼───────────┼ ┼────────────┼ ┼────────────┼ │ 參數之a │ <-- FP │ 參數之b │ <-- FP │ 參數之b │ ┼───────────┼ ┼────────────┼ ┼────────────┼ │ 函數返回值 │ <-- SP │ 參數之a │ <-- SP │ 參數之a │ <--FP ┼───────────┼ ┼────────────┼ ┼────────────┼ │ 函數返回值 │ <--SP zzz函數棧空間 ┼────────────┼ │ CALLER BP │ ┼────────────┼ │ zzz變數之2 │ ┼────────────┼ │ zzz變數之1 │ ┼────────────┼ │ zzz變數之0 │ ┼────────────┼ 低地址位
然後我們可以使用反彙編來對比我們自己實現的彙編程式碼版本和go源碼版本生成的彙編程式碼的區別:
我們自己彙編的版本:
TEXT main.yyy(SB) go/asm/xx.s xx.s:31 0x104f6b0 488b0424 MOVQ 0(SP), AX xx.s:32 0x104f6b4 4889442430 MOVQ AX, 0x30(SP) xx.s:33 0x104f6b9 488b442408 MOVQ 0x8(SP), AX xx.s:34 0x104f6be 48890424 MOVQ AX, 0(SP) xx.s:35 0x104f6c2 488b442410 MOVQ 0x10(SP), AX xx.s:36 0x104f6c7 4889442408 MOVQ AX, 0x8(SP) xx.s:37 0x104f6cc 488b442418 MOVQ 0x18(SP), AX xx.s:38 0x104f6d1 4889442410 MOVQ AX, 0x10(SP) xx.s:39 0x104f6d6 e865ffffff CALL main.zzz(SB) xx.s:40 0x104f6db 488b442430 MOVQ 0x30(SP), AX xx.s:41 0x104f6e0 48890424 MOVQ AX, 0(SP) xx.s:42 0x104f6e4 488b442428 MOVQ 0x28(SP), AX xx.s:43 0x104f6e9 4889442430 MOVQ AX, 0x30(SP) xx.s:44 0x104f6ee 488b442420 MOVQ 0x20(SP), AX xx.s:45 0x104f6f3 4889442428 MOVQ AX, 0x28(SP) xx.s:46 0x104f6f8 488b442418 MOVQ 0x18(SP), AX xx.s:47 0x104f6fd 4889442420 MOVQ AX, 0x20(SP) xx.s:48 0x104f702 c3 RET
go源碼版本生成的彙編:
TEXT main.yyy(SB) go/asm/main.go main.go:20 0x104f360 4883ec50 SUBQ $0x50, SP main.go:20 0x104f364 48896c2448 MOVQ BP, 0x48(SP) main.go:20 0x104f369 488d6c2448 LEAQ 0x48(SP), BP main.go:20 0x104f36e 48c744247000000000 MOVQ $0x0, 0x70(SP) main.go:20 0x104f377 48c744247800000000 MOVQ $0x0, 0x78(SP) main.go:20 0x104f380 48c784248000000000000000 MOVQ $0x0, 0x80(SP) main.go:20 0x104f38c 488b442458 MOVQ 0x58(SP), AX main.go:21 0x104f391 48890424 MOVQ AX, 0(SP) main.go:20 0x104f395 488b442460 MOVQ 0x60(SP), AX main.go:21 0x104f39a 4889442408 MOVQ AX, 0x8(SP) main.go:20 0x104f39f 488b442468 MOVQ 0x68(SP), AX main.go:21 0x104f3a4 4889442410 MOVQ AX, 0x10(SP) main.go:21 0x104f3a9 e892020000 CALL main.zzz(SB) main.go:21 0x104f3ae 488b442418 MOVQ 0x18(SP), AX main.go:21 0x104f3b3 4889442430 MOVQ AX, 0x30(SP) main.go:21 0x104f3b8 0f10442420 MOVUPS 0x20(SP), X0 main.go:21 0x104f3bd 0f11442438 MOVUPS X0, 0x38(SP) main.go:22 0x104f3c2 488b442430 MOVQ 0x30(SP), AX main.go:22 0x104f3c7 4889442470 MOVQ AX, 0x70(SP) main.go:22 0x104f3cc 0f10442438 MOVUPS 0x38(SP), X0 main.go:22 0x104f3d1 0f11442478 MOVUPS X0, 0x78(SP) main.go:22 0x104f3d6 488b6c2448 MOVQ 0x48(SP), BP main.go:22 0x104f3db 4883c450 ADDQ $0x50, SP main.go:22 0x104f3df c3 RET
經過對比可以看出我們的優點:
- 沒有額外分配棧空間
- 沒有中間變數,減少了拷貝次數
- 沒有中間變數的初始化,節省操作
go源碼版本的優點:
- 對於連續記憶體使用了
MOVUPS
命令優化,(此處不一定是優化,有時還會劣化,因為X86_64不同 指令集混用時,會產生額外開銷)
我們可以運行一下go benchmark
來比較一下兩個版本,可以看出自己的彙編版本速度上明顯快於go源碼版本。
go test -bench=. -v -count=3 goos: darwin goarch: amd64 BenchmarkYyyGoVersion-4 100000000 16.9 ns/op BenchmarkYyyGoVersion-4 100000000 17.0 ns/op BenchmarkYyyGoVersion-4 100000000 17.1 ns/op BenchmarkYyyAsmVersion-4 200000000 10.1 ns/op BenchmarkYyyAsmVersion-4 200000000 7.90 ns/op BenchmarkYyyAsmVersion-4 200000000 8.01 ns/op PASS ok go/asm 13.005s
回調函數/閉包
var num int func call(fn func(), n int) { fn() num += n } func basecall() { call(func() { num += 5 }, 1) }
如上面所示,當函數(call
)參數中包含回調函數(fn
)時,回調函數的指針通過一種簡介方式傳入,之所以採用這種設計也是為了照顧閉包調用的實現。接下來簡單介紹一下這種傳參。當一個函數的參數為一個函數時,其調用者與被調用者之間的關係如下圖所示:
caller +------------------+ | | +----------------------> -------------------- | | | | | caller parent BP | | BP(pseudo SP) -------------------- | | | | | Local Var0 | | -------------------- | | | | | ....... | | -------------------- caller stack frame | | | | Local VarN | ┼────────────┼ | |------------------| │ .... │ 如果是閉包時,可 | | | ┼────────────┼ 以擴展該區域存儲 | | callee arg1(n) | │ .... │ 閉包中的變數。 | |------------------| ┼────────────┼ | | | ---->│ fn pointer │ 間接臨時區域 | | callee arg0 | ┼────────────┼ | SP(Real Register) ----------------------------------------------+ FP(virtual register) | | | | | | return addr | parent return address | +----------------------> +------------------+--------------------------- <-------------------------------+ | caller BP | | | (caller frame pointer) | | BP(pseudo SP) ---------------------------- | | | | | Local Var0 | | ---------------------------- | | | | Local Var1 | ---------------------------- callee stack frame | | | ..... | ---------------------------- | | | | | Local VarN | | SP(Real Register) ---------------------------- | | | | | | | +--------------------------+ <-------------------------------+ callee
在golang的ABI中,關於回調函數、閉包的上下文由調用者(caller-basecall
)來維護,被調用者(callee-call
)直接按照規定的格式來使用即可。
- 調用者需要申請一段臨時記憶體區域來存儲函數(
func() { num+=5 }
)的指針,當傳遞參數是閉包時,該臨時記憶體區域開可以進行擴充,用於存儲閉包中捕獲的變數,通常編譯器將該記憶體區域定義成型為struct { F uintptr; a *int }
的結構。該臨時記憶體區域可以分配在棧上,也可以分配在堆上,還可以分配在暫存器上。到底分配在哪裡,需要編譯器根據逃逸分析的結果來決定; - 將臨時記憶體區域的地址存儲於對應被調用函數入參的對應位置上;其他參數按照上面的常規方法放置;
- 使用
CALL
執行調用被調用函數(callee-call
); - 在被調用函數(
callee-call
)中從對應參數位置中取出臨時記憶體區域的指針存儲於指定暫存器DX
(僅針對amd64平台) - 然後從
DX
指向的臨時記憶體區域的首部取出函數(func() { num+=5 }
)指針,存儲於AX
(此處暫存器可以任意指定) - 然後在執行
CALL AX
指令來調用傳入的回調函數。 - 當回調函數是閉包時,需要使用捕獲的變數時,直接通過集群器
DX
加對應偏移量來獲取。
下面結合幾個例子來理解:
例一
func callback() { println("xxx") } func call(fn func()) { fn() } func call1() { call(callback) } func call0()
其中call0
函數可以用彙編實現為:
TEXT ·call0(SB), $16-0 # 分配棧空間16位元組,8位元組為call函數的入參,8位元組為間接傳參的'臨時記憶體區域' LEAQ ·callback(SB), AX # 取·callback函數地址存儲於'臨時記憶體區域' MOVQ AX, fn-8(SP) # LEAQ fn-8(SP), AX # 取'臨時記憶體區域'地址存儲於call入參位置 MOVQ AX, fn-16(SP) # CALL ·call(SB) # 調用call函數 RET
注意:如果我們使用go tool compile -l -N -S
來獲取call1
的實現,可以的得到:
TEXT "".call1(SB), ABIInternal, $16-0 MOVQ (TLS), CX CMPQ SP, 16(CX) JLS 55 SUBQ $16, SP MOVQ BP, 8(SP) LEAQ 8(SP), BP FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) FUNCDATA $3, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB) PCDATA $2, $1 PCDATA $0, $0 # 以上是函數編譯器生成的棧管理,不用理會 LEAQ "".callback·f(SB), AX # 這部分,貌似沒有分配'臨時記憶體區域'進行中轉, PCDATA $2, $0 # 而是直接將函數地址賦值給call的參數。然後按 MOVQ AX, (SP) # 照這樣寫,會出現SIGBUS錯誤。對比之下,其貓 CALL "".call(SB) # 膩可能出現在`callback·f`上,此處可能包含 MOVQ 8(SP), BP # 一些隱藏資訊,因為手寫彙編採用這種格式是會 ADDQ $16, SP # 編譯錯誤的。 RET
例二
func call(fn func(), n int) { fn() } func testing() { var n int call(func() { n++ }, 1) _ = n }
其生成的彙編為:
TEXT testing.func1(SB), NOSPLIT|NEEDCTXT, $16-0 # NEEDCTXT標識閉包 MOVQ 8(DX), AX # 從DX+8偏移出取出捕獲參數n的指針 INCQ (AX) # 對參數n指針指向的記憶體執行++操作,n++ RET TEXT testing(SB), NOSPLIT, $56-0 MOVQ $0, n+16(SP) # 初始化棧上臨時變數n XORPS X0, X0 # 清空暫存器X0 MOVUPS X0, autotmp_2+32(SP) # 用X0暫存器初始化棧上臨時空間,該空間是分配給閉包的臨時記憶體區域 LEAQ autotmp_2+32(SP), AX # 取臨時記憶體區域指針到AX MOVQ AX, autotmp_3+24(SP) # 不知道此步有何用意,liveness? TESTB AL, (AX) LEAQ testing.func1(SB), CX # 將閉包函數指針存儲於臨時記憶體區域首部 MOVQ CX, autotmp_2+32(SP) TESTB AL, (AX) LEAQ n+16(SP), CX # 將臨時變數n的地址存儲於臨時記憶體區域尾部 MOVQ CX, autotmp_2+40(SP) MOVQ AX, (SP) # 將臨時記憶體區域地址賦值給call函數入參1 MOVQ $1, 8(SP) # 將立即數1賦值給call函數入參2 CALL call(SB) # 調用call函數 RET # func call(fn func(), n int) TEXT call(SB), NOSPLIT, $8-16 MOVQ "".fn+16(SP), DX # 取出臨時區域的地址到DX MOVQ (DX), AX # 對首部解引用獲取函數指針,存儲到AX CALL AX # 調用閉包函數 RET
直接調用C函數(FFI)
我們都知道CGO is not Go
,在go中調用C函數存在著巨大額外開銷,而一些短小精悍的C函數,我們可以考慮繞過CGO機制,直接調用,比如runtime
包中vDSO
的調用、fastcgo、rustgo等。要直接調用C函數,就要遵循C的ABI。
amd64 C ABI
在調用C函數時,主流有2種ABI:
Windows x64 C and C++ ABI
主要適用於各Windows平台System V ABI
主要適用於Solaris, Linux, FreeBSD, macOS等。
在ABI規定中,涉及內容繁多,下面簡單介紹一下System V ABI
中參數傳遞的協議:
- 當參數都是整數時,參數少於7個時, 參數從左到右放入暫存器:
rdi
,rsi
,rdx
,rcx
,r8
,r9
- 當參數都是整數時,參數為7個以上時, 前6個與前面一樣, 但後面的依次從
右向左
放入棧中,即和32位彙編一樣 H(a, b, c, d, e, f, g, h); => a->%rdi, b->%rsi, c->%rdx, d->%rcx, e->%r8, f->%r9 h->8(%esp) g->(%esp) CALL H - 如果參數中包含浮點數時,會利用xmm暫存器傳遞浮點數,其他參數的位置按順序排列
- 常用暫存器有16個,分為x86通用暫存器以及r8-r15暫存器
- 通用暫存器中,函數執行前後必須保持原始的暫存器有3個:是rbx、rbp、rsp
- rx暫存器中,最後4個必須保持原值:r12、r13、r14、r15(保持原值的意義是為了讓當前函數有可信任的暫存器,減小在函數調用過程中的保存/恢復操作。除了rbp、rsp用於特定用途外,其餘5個暫存器可隨意使用。)
由於該issue的存在,通常goroutine的棧空間很小,很可能產生棧溢出的錯誤。解決的方法有:
- 直接切換到
g0
棧,g0
棧是系統原生執行緒的棧,通常比較大而且與C兼容性更好,切換g0
棧的方式可以參考fastcgo中的實現,但是這有著強烈的版本依賴,不是很推薦; - 調用函數自身聲明一個很大的棧空間,迫使goroutine棧擴張。具體參考方法可以參考rustgo,該方法不能確定每一個C函數具體的棧空間需求,只能根據猜測分配一個足夠大的,同時也會造成比較大的浪費,也不推薦;
- 使用
runtime·systemstack
切換到g0
棧,同時擺脫了版本依賴。具體方法可以參考numa。
編譯/反編譯
因為go彙編的資料很少,所以我們需要通過編譯、反彙編來學習。
// 編譯 go build -gcflags="-S" go tool compile -S hello.go go tool compile -l -N -S hello.go // 禁止內聯 禁止優化 // 反編譯 go tool objdump <binary>
總結
了解go彙編並不是一定要去手寫它,因為彙編總是不可移植和難懂的。但是它能夠幫助我們了解go的一些底層機制, 了解電腦結構體系,同時我們需要做一些hack的事時可以用得到。
比如,我們可以使用go:noescape
來減少記憶體的分配:
很多時候,我們可以使函數內計算過程使用棧上的空間做快取,這樣可以減少對記憶體的使用,並且是計算速度更快:
func xxx() int{ var buf [1024]byte data := buf[:] // do something in data }
但是,很多時候,go編譯器的逃逸分析並不讓人滿意,經常會使buf
移動到堆記憶體上,造成不必要的記憶體分配。 這是我們可以使用sync.Pool
,但是總讓人不爽。因此我們使用彙編完成一個noescape
函數,繞過go編譯器的 逃逸檢測,使buf
不會移動到堆記憶體上。
// asm_amd64.s #include "textflag.h" TEXT ·noescape(SB),NOSPLIT,$0-48 MOVQ d_base+0(FP), AX MOVQ AX, b_base+24(FP) MOVQ d_len+8(FP), AX MOVQ AX, b_len+32(FP) MOVQ d_cap+16(FP),AX MOVQ AX, b_cap+40(FP) RET
//此處使用go編譯器的指示 //go:noescape func noescape(d []byte) (b []byte) func xxx() int { var buf [1024]byte data := noescape(buf[:]) // do something in data // 這樣可以確保buf一定分配在xxx的函數棧上 }
c2goasm
當我們需要做一些密集的數列運算或實現其他演算法時,我們可以使用先進CPU的向量擴展指令集進行加速,如sse4_2/avx/avx2/avx-512
等。有些人覺得通常可以遇不見這樣的場景,其實能夠用到這些的場景還是很多的。比如,我們常用的監控採集go-metrics庫,其中就有很多可以優化的地方,如SampleSum、SampleMax、SampleMin這些函數都可以進行加速。
但是,雖然這些方法很簡單,但是對於彙編基礎很弱的人來說,手寫這些sse4_2/avx/avx2/avx-512
指令程式碼,仍然是很困難的。但是,我們可以利用clang/gcc
這些深度優化過的C語言編譯器來幫我們生成對於的彙編程式碼。
所幸,這項工作已經有人幫我們很好的完成了,那就是c2goasm。c2goasm
可以將C/C++編譯器生成的彙編程式碼轉換為golang彙編程式碼。在這裡,我們可以學習該工具如何使用。它可以幫助我們在程式碼利用上sse4_2/avx/avx2/avx-512
等這些先進指令。但是這些執行需要得到CPU的支援。因此我們先要判斷使用的CPU程式碼是否支援。
注意c2goasm
中其中有很多默認規則需要我們去遵守:
- 我們先需要使用
clang
將c源文件編譯成彙編程式碼clang_c.s
(該文件名隨意); - 然後我們可以使用
c2goasm
將彙編程式碼clang_c.s
轉換成go彙編源碼xxx.s
; - 我們每使用
c2goasm
生成一個go彙編文件xxx.s
之前,我們先添加一個對應的xxx.go
的源碼文件,其中需要包含xxx.s
中函數的聲明。 - 當c源碼或者
clang_c.s
源碼中函數名稱為func_xxx
時,經過c2goasm
轉成的彙編函數會增加_
前綴,變成_func_xxx
,因此在xxx.go
中的函數聲明為_func_xxx
。要求聲明的_func_xxx
函數的入參個數與原來C源碼中的入參個數相等,且為每個64bit大小。此時go聲明函數中需要需要使用slice
/map
時,需要進行額外的轉化。如果函數有返回值,則聲明對應的go函數時,返回值必須為named return
,即返回值需要由()
包裹,否則會報錯:Badly formatted return argument ....
- 如果我們需要生成多種指令的go彙編實現時,我們需要實現對應的多個c函數,因此我們可以使用c的宏輔助我們聲明對應的c函數,避免重複的書寫。
在linux上,我們可以使用命令cat /proc/cpuinfo |grep flags
來查看CPU支援的指令集。但是在工作環境中,我們的程式碼需要在多個環境中運行,比如開發環境、和生產環境,這些環境之間可能會有很大差別,因此我們希望我們的程式碼可以動態支援不同的CPU環境。這裡,我們可以用到intel-go/cpuid,我們可以實現多個指令版本的程式碼,然後根據運行環境中CPU的支援情況,選擇實際實行哪一段邏輯:
package main import ( "fmt" "github.com/intel-go/cpuid" ) func main() { fmt.Println("EnabledAVX", cpuid.EnabledAVX) fmt.Println("EnabledAVX512", cpuid.EnabledAVX512) fmt.Println("SSE4_1", cpuid.HasFeature(cpuid.SSE4_1)) fmt.Println("SSE4_2", cpuid.HasFeature(cpuid.SSE4_2)) fmt.Println("AVX", cpuid.HasFeature(cpuid.AVX)) fmt.Println("AVX2", cpuid.HasExtendedFeature(cpuid.AVX2)) }
然後,我們可以先使用C來實現這3個函數:
#include <stdint.h> /* 我們要實現3中指令的彙編實現,因此我們需要生成3個版本的C程式碼,此處使用宏來輔助添加後綴,避免生成的函數名衝突 */ #if defined ENABLE_AVX2 #define NAME(x) x##_avx2 #elif defined ENABLE_AVX #define NAME(x) x##_avx #elif defined ENABLE_SSE4_2 #define NAME(x) x##_sse4_2 #endif int64_t NAME(sample_sum)(int64_t *beg, int64_t len) { int64_t sum = 0; int64_t *end = beg + len; while (beg < end) { sum += *beg++; } return sum; } int64_t NAME(sample_max)(int64_t *beg, int64_t len) { int64_t max = 0x8000000000000000; int64_t *end = beg + len; if (len == 0) { return 0; } while (beg < end) { if (*beg > max) { max = *beg; } beg++; } return max; } int64_t NAME(sample_min)(int64_t *beg, int64_t len) { if (len == 0) { return 0; } int64_t min = 0x7FFFFFFFFFFFFFFF; int64_t *end = beg + len; while (beg < end) { if (*beg < min) { min = *beg; } beg++; } return min; }
然後使用clang
生成三中指令的彙編程式碼:
clang -S -DENABLE_SSE4_2 -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 -msse4 lib/sample.c -o lib/sample_sse4.s clang -S -DENABLE_AVX -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 lib/sample.c -o lib/sample_avx.s 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 -mavx2 lib/sample.c -o lib/sample_avx2.s
注意:此處目前有一個待解決的問題issues8,如果誰指定如何解決,請幫助我一下。使用clang
生成的AVX2彙編程式碼,其中局部變數0x8000000000000000
/0x7FFFFFFFFFFFFFFF
會被分片到RODATA
段,並且使用32byte對其。使用c2goasm
轉換時,會生成一個很大的全局變數(幾個G…,此處會運行很久)。目前的解決方式是,將生成
.LCPI1_0: .quad -9223372036854775808 # 0x8000000000000000 .section .rodata,"a",@progbits .align 32 .LCPI1_1: .long 0 # 0x0 .long 2 # 0x2 .long 4 # 0x4 .long 6 # 0x6 .zero 4 .zero 4 .zero 4 .zero 4 .text .globl sample_max_avx2
改為:
.LCPI1_0: .quad -9223372036854775808 # 0x8000000000000000 .quad -9223372036854775808 # 0x8000000000000000 .quad -9223372036854775808 # 0x8000000000000000 .quad -9223372036854775808 # 0x8000000000000000 .section .rodata,"a",@progbits .LCPI1_1: .long 0 # 0x0 .long 2 # 0x2 .long 4 # 0x4 .long 6 # 0x6 .zero 4 .zero 4 .zero 4 .zero 4 .text .globl sample_max_avx2 .align 16, 0x90 .type sample_max_avx2,@function
另一處同理,具體修改後的結果為:sample_avx2.s
回歸正題,添加對應的go函數聲明,我們要生成的三個go彙編文件為:sample_sse4_amd64.s
,sample_avx_amd64.s
和sample_avx2_amd64.s
,因此對應的三個go文件為:sample_sse4_amd64.go
,sample_avx_amd64.go
和sample_avx2_amd64.go
。 其中聲明的go函數為下面,我們挑其中一個文件說,其他兩個類似:
package sample import "unsafe" // 聲明的go彙編函數,不支援go buildin 數據類型,參數個數要與c實現的參數個數相等,最多支援14個。 //go:noescape func _sample_sum_sse4_2(addr unsafe.Pointer, len int64) (x int64) //go:noescape func _sample_max_sse4_2(addr unsafe.Pointer, len int64) (x int64) //go:noescape func _sample_min_sse4_2(addr unsafe.Pointer, len int64) (x int64) // 因為我們希望輸入參數為一個slice,則我們在下面進行3個封裝。 func sample_sum_sse4_2(v []int64) int64 { x := (*slice)(unsafe.Pointer(&v)) return _sample_sum_sse4_2(x.addr, x.len) } func sample_max_sse4_2(v []int64) int64 { x := (*slice)(unsafe.Pointer(&v)) return _sample_max_sse4_2(x.addr, x.len) } func sample_min_sse4_2(v []int64) int64 { x := (*slice)(unsafe.Pointer(&v)) return _sample_min_sse4_2(x.addr, x.len) }
有了這些函數聲明,我們就可以使用c2goasm
進行轉換了:
c2goasm -a -f lib/sample_sse4.s sample_sse4_amd64.s c2goasm -a -f lib/sample_avx.s sample_avx_amd64.s c2goasm -a -f lib/sample_avx2.s sample_avx2_amd64.s
然後我們添加一段初始化邏輯,根據CPU支援的指令集來選擇使用對應的實現:
import ( "math" "unsafe" "github.com/intel-go/cpuid" ) var ( // SampleSum returns the sum of the slice of int64. SampleSum func(values []int64) int64 // SampleMax returns the maximum value of the slice of int64. SampleMax func(values []int64) int64 // SampleMin returns the minimum value of the slice of int64. SampleMin func(values []int64) int64 ) func init() { switch { case cpuid.EnabledAVX && cpuid.HasExtendedFeature(cpuid.AVX2): SampleSum = sample_sum_avx2 SampleMax = sample_max_avx2 SampleMin = sample_min_avx2 case cpuid.EnabledAVX && cpuid.HasFeature(cpuid.AVX): SampleSum = sample_sum_avx SampleMax = sample_max_avx SampleMin = sample_min_avx case cpuid.HasFeature(cpuid.SSE4_2): SampleSum = sample_sum_sse4_2 SampleMax = sample_max_sse4_2 SampleMin = sample_min_sse4_2 default: // 純go實現 SampleSum = sampleSum SampleMax = sampleMax SampleMin = sampleMin } }
此時我們的工作就完成了,我們可以使用go test
的benchmark來進行比較,看看跟之前的純go實現,性能提升了多少:
name old time/op new time/op delta SampleSum-4 519ns ± 1% 53ns ± 2% -89.72% (p=0.000 n=10+9) SampleMax-4 676ns ± 2% 183ns ± 2% -73.00% (p=0.000 n=10+10) SampleMin-4 627ns ± 1% 180ns ± 1% -71.27% (p=0.000 n=10+9)
我們可以看出,sum方法得到10倍的提升,max/min得到了3倍多的提升,可能是因為max/min方法中每次循環中都有一次分支判斷的原因,導致其提升效果不如sum方法那麼多。
完整的實現在lrita/c2goasm-example
RDTSC精確計時
在x86架構的CPU上,每個CPU上有一個單調遞增的時間戳暫存器,可以幫助我們精確計算每一段邏輯的精確耗時,其調用代價和計時精度遠遠優於time.Now()
,在runtime
中有著廣泛應用,可以參考runtime·cputicks
的實現。在但是對於指令比較複雜的函數邏輯並不適用於此方法,因為該暫存器時與CPU核心綁定,每個CPU核心上的暫存器可能並不一致,如果被測量的函數比較長,在運行過程中很可能發生CPU核心/執行緒的調度,使該函數在執行的過程中被調度到不同的CPU核心上,這樣測量前後取到的時間戳不是來自於同一個暫存器,從而造成比較大的誤差。
還要注意的是RDTSC
並不與其他指令串列,為了保證計時的準確性,需要在調用RDTSC
前增加對應的記憶體屏障,保證其準確性。
參考
- A Quick Guide to Go』s Assembler
- 解析 Go 中的函數調用
- A Manual for the Plan 9 assembler
- Golang中的Plan9彙編器
- GoFunctionsInAssembly
- plan9 assembly 完全解析
- InfluxData is Building a Fast Implementation of Apache Arrow in Go Using c2goasm and SIMD
- RDTSC指令