V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
dengn
V2EX  ›  Go 编程语言

Go 汇编语法和 MatrixOne 使用介绍

  •  1
     
  •   dengn · 2022-04-19 14:37:21 +08:00 · 1500 次点击
    这是一个创建于 739 天前的主题,其中的信息可能已经有所发展或是发生改变。

    目录

    • [MatrixOne 数据库是什么?](#MatrixOne 数据库是什么?)
    • [Go 汇编介绍](#Go 汇编介绍)
    • [为什么使用 Go 汇编?](#为什么使用 Go 汇编?)
      • [为什么不用 CGO ?](#为什么不用 CGO ?)
    • [Go 汇编语法特点](#Go 汇编语法特点)
    • [对写 Go 汇编代码有帮助的工具](#对写 Go 汇编代码有帮助的工具)
      • avo
      • text/template
      • [在 Go 汇编代码中使用宏](#在 Go 汇编代码中使用宏)
    • [在 MatrixOne 数据库中的 Go 语言汇编应用](#在 MatrixOne 数据库中的 Go 语言汇编应用)
    • [MatrixOne 社区](#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/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 社区群 技术交流

    4 条回复    2022-04-19 16:22:00 +08:00
    faceair
        1
    faceair  
       2022-04-19 15:02:51 +08:00
    文档域名 docs.matrixorigin.cn 似乎还没有解析
    dengn
        2
    dengn  
    OP
       2022-04-19 15:21:58 +08:00
    @faceair readme 里的域名打错了,中文文档是这个 https://docs.matrixorigin.io/cn/0.3.0/
    CodeCore
        3
    CodeCore  
       2022-04-19 15:39:06 +08:00
    支持
    tfull
        4
    tfull  
       2022-04-19 16:22:00 +08:00
    时间只支持到秒,感觉不如一步到位纳秒
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   3024 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 35ms · UTC 08:16 · PVG 16:16 · LAX 01:16 · JFK 04:16
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.