Go彙編語法和MatrixOne使用介紹

dengn_matrixorigin發表於2022-04-19

目錄

MatrixOne資料庫是什麼?

MatrixOne是一個新一代超融合異構資料庫,致力於打造單一架構處理TP、AP、流計算等多種負載的極簡大資料引擎。MatrixOne由Go語言所開發,並已於2021年10月開源,目前已經release到0.3版本。在MatrixOne已釋出的效能報告中,與業界領先的OLAP資料庫Clickhouse相比也不落下風。作為一款Go語言實現的資料庫,可以達到C++實現的資料庫一樣的效能,其中一個很重要的優化就是利用Go語言自帶的彙編能力,來通過呼叫SIMD指令進行硬體加速。本文就將對Go彙編及在MatrixOne的應用做詳細介紹。

Github地址:https://github.com/matrixorigin/matrixone 有興趣的讀者歡迎star和fork。

Go彙編介紹

Go是一種較新的高階語言,提供諸如協程、快速編譯等激動人心的特性。但是在資料庫引擎中,使用純粹的Go語言會有力所未逮的時候。例如,向量化是資料庫計算引擎常用的加速手段,而Go語言無法通過呼叫SIMD指令來使向量化程式碼的效能最大化。又例如,在安全相關程式碼中,Go語言無法呼叫CPU提供的密碼學相關指令。在C/C++/Rust的世界中,解決這類問題可通過呼叫CPU架構相關的intrinsics函式。而Go語言提供的解決方案是Go彙編。本文將介紹Go彙編的語法特點,並通過幾個具體場景展示其使用方法。

本文假定讀者已經對計算機體系架構和組合語言有基本的瞭解,因此常用的名詞(比如“暫存器”)不做解釋。如缺乏相關預備知識,可以尋求網路資源進行學習,例如這裡

如無特殊說明,本文所指的組合語言皆針對x86(amd64)架構。關於x86指令集,IntelAMD官方都提供了完整的指令集參考文件。想快速查閱,也可以使用這個列表。Intel的intrinsics文件也可以作為一個參考。

為什麼使用Go彙編?

維基百科把使用匯編語言的理由概括成3類:

  • 直接操作硬體
  • 使用特殊的CPU指令
  • 解決效能問題

Go程式設計師使用匯編的理由,也不外乎這3類。如果你面對的問題在這3個類別裡面,並且沒有現成的庫可用,就可以考慮使用Go彙編。

為什麼不用CGO?

  • 巨大的函式呼叫開銷
  • 記憶體管理問題
  • 打破goroutine語義 若協程裡執行CGO函式,會佔據單獨執行緒,無法被Go執行時正常排程。
  • 可移植性差 交叉編譯需要目的平臺的全套工具鏈。在不同平臺部署需要安裝更多依賴庫。

倘若在你的場景中以上幾點無法接受,不妨嘗試一下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放入暫存器CX
  • CX加上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。

對寫Go彙編程式碼有幫助的工具

avo

熟悉組合語言的讀者應該知道,手寫組合語言,會有選擇暫存器、計算偏移量等繁瑣且易出錯的步驟。avo庫就是為解決此類問題而生。如欲瞭解avo的具體用法,請參見其repo中給出的樣例

text/template

這是Go語言自帶的一個庫。在寫大量重複程式碼時會有幫助,例如在向量化程式碼中為不同型別實現相同基本運算元。具體用法參見官方文件,這裡不佔用篇幅。

在Go彙編程式碼中使用巨集

Go彙編程式碼支援跟C語言類似的巨集,也可以用在程式碼大量重複的場景。內部庫中就有很多例子,比如這裡

在MatrixOne資料庫中的Go語言彙編應用

基本向量運算加速

在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])
    }
    for i := 0; i < unroll; i++ {
        VPADDB(y.Offset(regWidth*i), xs[i], xs[i])
    }
    for i := 0; i < unroll; 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
	VMOVDQU (AX), Y0
	VPADDB  (CX), Y0, Y0
	VMOVDQU Y0, (DX)
	ADDQ    $0x00000020, AX
	ADDQ    $0x00000020, CX
	ADDQ    $0x00000020, DX
	SUBQ    $0x00000020, BX
	JMP     int8AddTailLoop

int8AddDone:
	RET

可以看到,在avo程式碼中,我們只需要給變數指定暫存器型別,生成彙編的時候會自動幫我們繫結相應型別的可用暫存器。在很多場景下這確實能夠帶來方便。不過avo目前只支援x86架構,給arm CPU寫彙編無法使用。

Go語言無法直接呼叫的指令

除了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指令打交道的組合語言。

MatrixOne社群

對MatrixOne有興趣的話可以關注矩陣起源公眾號或者加入MatrixOne社群。

微信公眾號 矩陣起源
微信公眾號 矩陣起源

MatrixOne社群群 技術交流
MatrixOne社群群 技術交流

相關文章