目录
- MatrixOne 数据库是什么?
- Go 汇编介绍
-
为什么应用 Go 汇编?
- 为什么不必 CGO?
-
Go 汇编语法特点
- 操作数程序
- 寄存器宽度标识
- 函数调用约定
-
对写 Go 汇编代码有帮忙的工具
- avo
- text/template
- 在 Go 汇编代码中应用宏
-
在 MatrixOne 数据库中的 Go 语言汇编利用
- 根本向量运算减速
- Go 语言无奈间接调用的指令
- 编译器无奈达到的非凡优化成果
- MatrixOne 社区
MatrixOne 数据库是什么?
MatrixOne 是一个新一代超交融异构数据库,致力于打造繁多架构解决 TP、AP、流计算等多种负载的极简大数据引擎。MatrixOne 由 Go 语言所开发,并已于 2021 年 10 月开源,目前曾经 release 到 0.3 版本。在 MatrixOne 已公布的性能报告中,与业界当先的 OLAP 数据库 Clickhouse 相比也不落下风。作为一款 Go 语言实现的数据库,能够达到 C ++ 实现的数据库一样的性能,其中一个很重要的优化就是利用 Go 语言自带的汇编能力,来通过调用 SIMD 指令进行硬件加速。本文就将对 Go 汇编及在 MatrixOne 的利用做具体介绍。
Github 地址:https://github.com/matrixorig… 有趣味的读者欢送 star 和 fork。
Go 汇编介绍
Go 是一种较新的高级语言,提供诸如协程、疾速编译等激动人心的个性。然而在数据库引擎中,应用纯正的 Go 语言会无力所未逮的时候。例如,向量化是数据库计算引擎罕用的减速伎俩,而 Go 语言无奈通过调用 SIMD 指令来使向量化代码的性能最大化。又例如,在平安相干代码中,Go 语言无奈调用 CPU 提供的密码学相干指令。在 C /C++/Rust 的世界中,解决这类问题可通过调用 CPU 架构相干的 intrinsics 函数。而 Go 语言提供的解决方案是 Go 汇编。本文将介绍 Go 汇编的语法特点,并通过几个具体场景展现其应用办法。
本文假设读者曾经对计算机体系架构和汇编语言有根本的理解,因而罕用的名词(比方“寄存器”)不做解释。如不足相干准备常识,能够寻求网络资源进行学习,例如这里。
如无非凡阐明,本文所指的汇编语言皆针对 x86(amd64)架构。对于 x86 指令集,Intel 和 AMD 官网都提供了残缺的指令集参考文档。想疾速查阅,也能够应用这个列表。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 社区群 技术交换