您好,登錄后才能下訂單哦!
這篇文章主要介紹了Go匯編語法和MatrixOne使用實例分析的相關知識,內容詳細易懂,操作簡單快捷,具有一定借鑒價值,相信大家閱讀完這篇Go匯編語法和MatrixOne使用實例分析文章都會有所收獲,下面我們一起來看看吧。
MatrixOne是一個新一代超融合異構數據庫,致力于打造單一架構處理TP、AP、流計算等多種負載的極簡大數據引擎。MatrixOne由Go語言所開發,并已于2021年10月開源,目前已經release到0.3版本。在MatrixOne已發布的性能報告中,與業界領先的OLAP數據庫Clickhouse相比也不落下風。作為一款Go語言實現的數據庫,可以達到C++實現的數據庫一樣的性能,其中一個很重要的優化就是利用Go語言自帶的匯編能力,來通過調用SIMD指令進行硬件加速。
Go是一種較新的高級語言,提供諸如協程、快速編譯等激動人心的特性。但是在數據庫引擎中,使用純粹的Go語言會有力所未逮的時候。例如,向量化是數據庫計算引擎常用的加速手段,而Go語言無法通過調用SIMD指令來使向量化代碼的性能最大化。又例如,在安全相關代碼中,Go語言無法調用CPU提供的密碼學相關指令。在C/C++/Rust的世界中,解決這類問題可通過調用CPU架構相關的intrinsics函數。而Go語言提供的解決方案是Go匯編。本文將介紹Go匯編的語法特點,并通過幾個具體場景展示其使用方法。
本文假定讀者已經對計算機體系架構和匯編語言有基本的了解,因此常用的名詞(比如“寄存器”)不做解釋。如缺乏相關預備知識,可以尋求網絡資源進行學習,例如這里。
如無特殊說明,本文所指的匯編語言皆針對x86(amd64)架構。關于x86指令集,Intel和AMD官方都提供了完整的指令集參考文檔。想快速查閱,也可以使用這個列表。Intel的intrinsics文檔也可以作為一個參考。
維基百科把使用匯編語言的理由概括成3類:
直接操作硬件
使用特殊的CPU指令
解決性能問題
Go程序員使用匯編的理由,也不外乎這3類。如果你面對的問題在這3個類別里面,并且沒有現成的庫可用,就可以考慮使用Go匯編。
巨大的函數調用開銷
內存管理問題
打破goroutine語義 若協程里運行CGO函數,會占據單獨線程,無法被Go運行時正常調度。
可移植性差 交叉編譯需要目的平臺的全套工具鏈。在不同平臺部署需要安裝更多依賴庫。
倘若在你的場景中以上幾點無法接受,不妨嘗試一下Go匯編。
根據Rob Pike的The Design of the Go Assembler,Go使用的匯編語言并不嚴格與CPU指令一一對應,而是一種被稱作Plan 9 assembly的“偽匯編”。
The most important thing to know about Go's assembler is that it is not a direct representation of the underlying machine. Some of the details map precisely to the machine, but some do not. This is because the compiler suite needs no assembler pass in the usual pipeline. Instead, the compiler operates on a kind of semi-abstract instruction set, and instruction selection occurs partly after code generation. The assembler works on the semi-abstract form, so when you see an instruction like MOV what the toolchain actually generates for that operation might not be a move instruction at all, perhaps a clear or load. Or it might correspond exactly to the machine instruction with that name. In general, machine-specific operations tend to appear as themselves, while more general concepts like memory move and subroutine call and return are more abstract. The details vary with architecture, and we apologize for the imprecision; the situation is not well-defined.
我們不用關心Plan 9 assembly與機器指令的對應關系,只需要了解Plan 9 assembly的語法特點。網絡上有一些可獲得的文檔,如這里和這里。
一例勝千言,下面我們以最簡單的64位整數加法為例,從不同方面來看Go匯編語法的特點。
// add.go func Add(x, y int64) int64
//add_amd64.s #include "textflag.h" TEXT ·Add(SB), NOSPLIT, $0-24 MOVQ x+0(FP), AX MOVQ y+8(FP), CX ADDQ AX, CX MOVQ CX, ret+16(FP) RET
這四條匯編代碼所做的依次是:
第一個操作數x放入寄存器AX
第二個操作數y放入寄存器
CXCX加上AX,結果放回CX
CX放入返回值所在棧地址
x86匯編最常用的語法有兩種,AT&T語法和Intel語法。AT&T語法結果數放在最后,其他操作數放在前面。Intel語法結果數放最前面,其他操作數在后面。
Go的匯編在這方面接近AT&T語法,結果數放最后。
一個容易寫錯的例子是CMP指令。從效果上來看,CMP類似于SUB指令只修改EFLAGS標志位,不修改操作數。而在Go匯編中,CMP是以第一個操作數減去第二個操作數(與SUB相反)的結果來設置標志位。
部分指令支持不同的寄存器寬度。以64位操作數的ADD為例,按AT&T語法,指令名要加上寬度后綴變成ADDQ,寄存器也要加上寬度前綴變成RAX和RCX。按Intel語法,指令名不變,只給寄存器加上前綴。
上面例子可以看出,Go匯編跟兩者都不同:指令名需要加寬度后綴,寄存器不變。
編程語言在函數調用中傳遞參數的方式,稱做函數調用約定(function calling convention)。x86-64架構上的主流C/C++編譯器,都默認使用基于寄存器的方式:調用者把參數放進特定的寄存器傳給被調用函數。而Go的調用約定,簡單地講,在最新的Go 1.18上,Go自己的runtime庫在amd64與arm64與ppc64架構上使用基于寄存器的方式,其余地方(其他的CPU架構,以及非runtime庫和用戶寫的庫)使用基于棧的方式:調用者把參數依次壓棧,被調用者通過傳遞的偏移量去棧中訪問,執行結束后再把返回值壓棧。
在上面代碼中,FP是一個虛擬寄存器,指向第一個參數在棧中的地址。多個參數和返回值會按順序對齊存放,因此x,y,返回值在棧中地址分別是FP加上偏移量0,8,16。
熟悉匯編語言的讀者應該知道,手寫匯編語言,會有選擇寄存器、計算偏移量等繁瑣且易出錯的步驟。avo庫就是為解決此類問題而生。如欲了解avo的具體用法,請參見其repo中給出的樣例。
這是Go語言自帶的一個庫。在寫大量重復代碼時會有幫助,例如在向量化代碼中為不同類型實現相同基本算子。具體用法參見官方文檔,這里不占用篇幅。
Go匯編代碼支持跟C語言類似的宏,也可以用在代碼大量重復的場景。內部庫中就有很多例子,比如這里。
在OLAP數據庫計算引擎中,向量化是必不可少的加速手段。通過向量化,消除了大量簡單函數調用帶來的不必要開銷。而為了達到最大的向量化性能,使用SIMD指令是十分自然的選擇。
我們以8位整數向量化加法為例。將兩個數組的元素兩兩相加,把結果放入第三個數組。這樣的操作在某些C/C++編譯器中,可以自動優化成使用SIMD指令的版本。而以編譯速度見長的Go編譯器,不會做這樣的優化。這也是Go語言為了保證編譯速度所做的主動選擇。在這個例子中,我們介紹如何使用Go匯編以AVX2指令集實現int8類型向量加法(假設數組已經按32字節填充)。
由于AVX2一共有16個256位寄存器,我們希望在循環展開中把它們全部使用上。如果完全手寫的話,重復羅列寄存器非常繁瑣且容易出錯。因此我們使用avo來簡化一些工作。avo的向量加法代碼如下:
package main import ( . "github.com/mmcloughlin/avo/build" . "github.com/mmcloughlin/avo/operand" . "github.com/mmcloughlin/avo/reg" ) var unroll = 16 var regWidth = 32 func main() { TEXT("int8AddAvx2Asm", NOSPLIT, "func(x []int8, y []int8, r []int8)") x := Mem{Base: Load(Param("x").Base(), GP64())} y := Mem{Base: Load(Param("y").Base(), GP64())} r := Mem{Base: Load(Param("r").Base(), GP64())} n := Load(Param("x").Len(), GP64()) blocksize := regWidth * unroll blockitems := blocksize / 1 regitems := regWidth / 1 Label("int8AddBlockLoop") CMPQ(n, U32(blockitems)) JL(LabelRef("int8AddTailLoop")) xs := make([]VecVirtual, unroll) for i := 0; i < unroll; i++ { xs[i] = YMM() VMOVDQU(x.Offset(regWidth*i), xs[i]) } VPADDB(y.Offset(regWidth*i), xs[i], xs[i]) VMOVDQU(xs[i], r.Offset(regWidth*i)) ADDQ(U32(blocksize), x.Base) ADDQ(U32(blocksize), y.Base) ADDQ(U32(blocksize), r.Base) SUBQ(U32(blockitems), n) JMP(LabelRef("int8AddBlockLoop")) Label("int8AddTailLoop") CMPQ(n, U32(regitems)) JL(LabelRef("int8AddDone")) VMOVDQU(x, xs[0]) VPADDB(y, xs[0], xs[0]) VMOVDQU(xs[0], r) ADDQ(U32(regWidth), x.Base) ADDQ(U32(regWidth), y.Base) ADDQ(U32(regWidth), r.Base) SUBQ(U32(regitems), n) JMP(LabelRef("int8AddTailLoop")) Label("int8AddDone") RET() }
運行命令
go run int8add.go -out int8add.s
之后生成的匯編代碼如下:
// Code generated by command: go run int8add.go -out int8add.s. DO NOT EDIT. #include "textflag.h" // func int8AddAvx2Asm(x []int8, y []int8, r []int8) // Requires: AVX, AVX2 TEXT ·int8AddAvx2Asm(SB), NOSPLIT, $0-72 MOVQ x_base+0(FP), AX MOVQ y_base+24(FP), CX MOVQ r_base+48(FP), DX MOVQ x_len+8(FP), BX int8AddBlockLoop: CMPQ BX, $0x00000200 JL int8AddTailLoop VMOVDQU (AX), Y0 VMOVDQU 32(AX), Y1 VMOVDQU 64(AX), Y2 VMOVDQU 96(AX), Y3 VMOVDQU 128(AX), Y4 VMOVDQU 160(AX), Y5 VMOVDQU 192(AX), Y6 VMOVDQU 224(AX), Y7 VMOVDQU 256(AX), Y8 VMOVDQU 288(AX), Y9 VMOVDQU 320(AX), Y10 VMOVDQU 352(AX), Y11 VMOVDQU 384(AX), Y12 VMOVDQU 416(AX), Y13 VMOVDQU 448(AX), Y14 VMOVDQU 480(AX), Y15 VPADDB (CX), Y0, Y0 VPADDB 32(CX), Y1, Y1 VPADDB 64(CX), Y2, Y2 VPADDB 96(CX), Y3, Y3 VPADDB 128(CX), Y4, Y4 VPADDB 160(CX), Y5, Y5 VPADDB 192(CX), Y6, Y6 VPADDB 224(CX), Y7, Y7 VPADDB 256(CX), Y8, Y8 VPADDB 288(CX), Y9, Y9 VPADDB 320(CX), Y10, Y10 VPADDB 352(CX), Y11, Y11 VPADDB 384(CX), Y12, Y12 VPADDB 416(CX), Y13, Y13 VPADDB 448(CX), Y14, Y14 VPADDB 480(CX), Y15, Y15 VMOVDQU Y0, (DX) VMOVDQU Y1, 32(DX) VMOVDQU Y2, 64(DX) VMOVDQU Y3, 96(DX) VMOVDQU Y4, 128(DX) VMOVDQU Y5, 160(DX) VMOVDQU Y6, 192(DX) VMOVDQU Y7, 224(DX) VMOVDQU Y8, 256(DX) VMOVDQU Y9, 288(DX) VMOVDQU Y10, 320(DX) VMOVDQU Y11, 352(DX) VMOVDQU Y12, 384(DX) VMOVDQU Y13, 416(DX) VMOVDQU Y14, 448(DX) VMOVDQU Y15, 480(DX) ADDQ $0x00000200, AX ADDQ $0x00000200, CX ADDQ $0x00000200, DX SUBQ $0x00000200, BX JMP int8AddBlockLoop int8AddTailLoop: CMPQ BX, $0x00000020 JL int8AddDone ADDQ $0x00000020, AX ADDQ $0x00000020, CX ADDQ $0x00000020, DX SUBQ $0x00000020, BX JMP int8AddTailLoop int8AddDone: RET
可以看到,在avo代碼中,我們只需要給變量指定寄存器類型,生成匯編的時候會自動幫我們綁定相應類型的可用寄存器。在很多場景下這確實能夠帶來方便。不過avo目前只支持x86架構,給arm CPU寫匯編無法使用。
除了SIMD,還有很多Go語言本身無法使用到的CPU指令,比如密碼學相關指令。如果是用C/C++,可以使用編譯器內置的intrinsics函數(gcc和clang皆提供)來調用,還算方便。遺憾的是Go語言并不提供intrinsics函數。遇到這樣的場景,匯編是唯一的解決辦法。Go語言自己的crypto官方庫里就有大量的匯編代碼。
這里我們以CRC32C指令作為例子。在MatrixOne的哈希表實現中,整數key的哈希函數只使用一條CRC32指令,達到了理論上的最高性能。代碼如下:
TEXT ·Crc32Int64Hash(SB), NOSPLIT, $0-16 MOVQ -1, SI CRC32Q data+0(FP), SI MOVQ SI, ret+8(FP) RET
實際代碼中,為了消除匯編函數調用帶來的指令跳轉開銷,以及參數進出棧開銷,使用的是批量化的版本。這里為了節約篇幅,我們用簡化版舉例。
下面是MatrixOne使用的兩個有序64位整數數組求交集的算法的一部分:
... loop: CMPQ DX, DI JE done CMPQ R11, R8 JE done MOVQ (DX), R10 MOVQ R10, (SI) CMPQ R10, (R11) SETLE AL SETGE BL SETEQ CL SHLB $0x03, AL SHLB $0x03, BL SHLB $0x03, CL ADDQ AX, DX ADDQ BX, R11 ADDQ CX, SI JMP loop done: ...
CMPQ R10, (R11)
這一行,是比較兩個數組當前指針位置的元素。后面幾行根據這個比較的結果,來移動對應操作數數組及結果數組的指針。文字解釋不如對比下面等價的C語言代碼來得清楚:
while (true) { if (a == a_end) break; if (b == b_end) break; *c = *a; if (*a <= *b) ++a; if (*a >= *b) ++b; if (*a == *b) ++c; }
匯編代碼中,循環體內只做了一次比較運算,并且沒有任何的分支跳轉。高級語言編譯器達不到這樣的優化效果,原因是任何高級語言都不提供“根據一個比較運算的3種不同結果,分別修改3個不同的數”這樣直接跟CPU指令集相關的語義。
這個例子算是對匯編語言威力的一個展示。編程語言不斷發展,抽象層次越來越高,但是在性能最大化的場景下,仍然需要直接與CPU指令打交道的匯編語言。
關于“Go匯編語法和MatrixOne使用實例分析”這篇文章的內容就介紹到這里,感謝各位的閱讀!相信大家對“Go匯編語法和MatrixOne使用實例分析”知識都有一定的了解,大家如果還想學習更多知識,歡迎關注億速云行業資訊頻道。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。