| 导语IEG增值服务部 - 技术藏经阁:秉承IEG增值服务部核心的创新向上的理念,利用KM知识分享开放平台来沉淀和输出部门内的核心技术相关能力。构建与公司团队共同交流、共同成长的开放性知识K吧,更多文章请点击: http://km.woa.com/group/vasknowlege

前言

Go由于简单易学、语言层面支持并发、容易部署等优点,越来越受到大家的欢迎。但是由于某些原因,Go还没有提供语言级的SIMD函数,编译优化也没有Clang等其他编译器做得更深入,因此在某些考虑性能或成本的场景下,C/C++更具优势。本人之前研究了字节的高性能库sonic,借鉴其中使用C重写热点函数的思路,另外考虑直接调用用C重写的函数的场景,给出使用C重写Go中cpu密集型函数的一般方法。

1 分析程序中是否存在cpu热点

首先分析服务中cpu操作热点分布,查看是否存在优化的必要。如果没有明显的cpu热点函数,则没有必要引入本文的方法引入开发编译的复杂度。

1)使用工具分析

可以使用工具如pprof,Go的性能分析工具trace来分析cpu热点,相关的资料比较多,这里不再赘述。

2)明显的cpu密集操作

如果存在大数据量的向量操作,则可以使用文中的方法优化。

2 使用C编写热点函数

为什么不使用cgo

调用C函数的时候,必须切换当前的栈为线程的主栈,这带来了两个比较严重的问题:

线程的栈在Go运行时是比较少的,受到P/M数量的限制,一般可以简单的理解成受到GOMAXPROCS限制; 由于需要同时保留C/C++的运行时,CGO需要在两个运行时和两个ABI(抽象二进制接口)之间做翻译和协调。这就带来了很大的开销。

2.1 golang与C类型转换

Go与C数据类型对照表

go类型

c类型

unsafe.Pointer

void *

uint64

uint64_t

int

ssize_t

GoString

string

GoSlice

string

type GoString struct { Ptr unsafe.Pointer Len int}type GoSlice struct { Ptr unsafe.Pointer Len int Cap int}

既然要用C重写热点函数,则有必要给出一些写出高性能C代码的方法。考虑通用性,这里列出一些非业务逻辑、算法相关的几种可以提高性能的方法。

1)loop unrolling

loop unrolling是一种减少循环退出判断操作的方法,比如下面的代码片段

int sum = 0;for (unsigned int i = 0; i < 100; i++) { sum += i;}

可以通过loop unrolling方法修改为

int sum = 0;for (unsigned int i = 0; i < 100; i+=5) { sum += i; sum += i + 1; sum += i + 2; sum += i + 3; sum += i + 4;}

将i<100的执行次数从101次减少到21次。

缺点:

loop unrolling会导致代码膨胀,从而增加内存开销,如果是服务端场景,增加的内存开销是微不足道的。

2)SIMD

SIMD是Single Instruction Multiple Data的缩写,即单指令流多数据流,同时对多个数据执行相同的操作。 使用SIMD有几种方法,比如使用Intel提供的封装了SIMD的库、借助编译器自动向量化、有的编译器(如Cilk)支持的编译器指示符#pragma simd强制将循环向量化、使用内置函数intrinsics。

intrinsics指令的示例如下,一次执行8个float值的加法。

int main(){__m128 v0 = _mm_set_ps(1.0f, 2.0f, 3.0f, 4.0f);__m128 v1 = _mm_set_ps(1.0f, 2.0f, 3.0f, 4.0f);__m128 result = _mm_add_ps(v0, v1);}

这里不展开几种指令集下的函数列表和用法,详见Intel intrinsics Guide。

3)减少cache miss

一起使用的函数声明定义在一起; 一起使用的变量存储在一起,通过结构体的方式整理到一起,或者定义为局部变量。 变量尽可能的在靠近第一次使用的位置声明和定义,即就近原则(Principle of Proximity)。 动态申请的变量是cache不友好的,如stl容器、string,可以的话避免使用。

4)减少函数调用开销

小函数使用内联

使用迭代而不是递归

5)减少分支

使用计算减少分支

长的if else改成switch

出现概率更高条件放在前面

6)Strength reduction

这里指的是将cpu开销较大的运算修改为开销较低的运算,包括但不限于以下场景:

优先使用位操作(位操作的性能高于加减乘除等操作); 优先使用无符号数(无符号数的性能优于有符号数); 尽量不要使用浮点数(浮点数),如通过舍弃不必要的精度、小数点后位数有限的值可以用整数保存等方法;

2.4 编译

c语言编写的函数编译成Go可以调用的汇编语言,步骤如下图:



2.4.1 编译成x86汇编

使用Clang汇编

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 -mavx add.c -o add.s

这里示例的参数为ENABLE_AVX2,即AVX2指令集。编译时需要编译多次,生成每个指令集的汇编文件,Go程序启动时根据指令集选择使用的文件。

2.4.2 转化成plan9汇编

Go使用的汇编为plan9汇编,而clang编译出来的为x86汇编,需要转化为plan9汇编。

本文在3和4分别给出直接调用和热点函数组装两种调用方式:直接调用使用c2goasm直接转换的plan9汇编文件即可;组合调用的方式需要获取每个热点函数的地址,基于函数调用开销考虑,参考字节的sonic使用另一个转换工具asm2asm。

3 直接调用

直接调用C编译出来的汇编代码,需要先将x86汇编转换为plan9汇编,然后使用桩函数调用即可。

3.1 示例目录结构

可以参考下面的示例目录结构来组织代码:

.├── go.mod├── go.sum├── lib│ ├── add_amd64.go // 桩函数定义,从native/add_amd64.go拷贝│ └── add_amd64.s // plan9汇编代码,从native/add_amd64.s拷贝├── main.go└── native ├── add_amd64.go // 桩函数定义 ├── add_amd64.s // 输出的plan9汇编文件 ├── add.c // C代码源文件 ├── add.s // C编译出来的X84汇编文件 ├── c2goasm // x86汇编转plan9汇编工具 ├── asm2plan9s // c2goasm依赖的工具,用于生成Byte序列 └── gen_asm.sh // 更新lib目录下的桩函数和汇编代码的脚本,包含编译,汇编转换,拷贝等操作

其中:native为C文件、桩函数和转换的工作目录;lib为go程序运行时使用的热点函数目录。目录内各个文件的含义见上面的注释。

asm2plan9s为c2goasm依赖的库,需要安装并将安装目录添加到PATH环境变量中。



3.2 定义桩函数

Go调用汇编需要定义与汇编函数定义相同的桩函数,并使用指针类型的入参传参。

例如如下C代码:

void Add(int a, int b, int* result) { int sum = 0; sum = a + b; *result = sum;}

对应的桩函数为:

//+build !noasm//+build !appenginepackage libimport "unsafe"//go:noescape//go:nosplitfunc _Add(a, b int, result unsafe.Pointer)func Add(a, b int) int { var sum int _Add(a, b, unsafe.Pointer(&sum)) return sum}

其中,_Add为桩函数定义。桩函数通过指针传递返回值,为了更方便调用,可以在封装export的函数Add时修改为通过返回值传递返回值。

3.3 转换成plan9汇编

使用c2goasm将C语言直接编译出来的x86汇编转化为plan9汇编。

./c2goasm -a add.s add_amd64.s

其中,示例文件add.s为x86汇编文件,add_amd64.s为转换后的plan9汇编文件。需要注意的是,_amd64文件名后缀是必须的。

3.4 拷贝到运行时目录

将native目录中生成的plan9汇编和桩函数拷贝到运行目录lib中cp add_amd64* ../lib。

4 组合调用

如果一次函数使用到多个热点函数,则需要将这些热点函数组合起来。



组合拼接的代码是汇编指令,因此本章先介绍一些golang汇编的基本知识,然后介绍怎么将多个热点函数拼接起来。

需要说明的是:手写汇编是非常不推荐的,原因是首先比较难写,容易出错,另外不能利用编译器的优化能力,写出的代码效率不一定最优。

4.0 go汇编简介(plan9汇编)

入参

golang 1.17版本之后函数调用是通过寄存器传参的,按照参数的顺序,分别赋值给AX、BX、CX、DI等寄存器。文中后面的代码以1.17以后得版本为例。

汇编函数入参

热点函数的入参为DI、SI、DX等寄存器。调用汇编函数之前需要将参数按照顺序写入这几个寄存器之中。

这里需要注意的是,plan9汇编为caller-save,如果callee中使用了当前保存暂存结果寄存器,寄存器中的值需要callerb保存到其他寄存器或者栈中。

出参

出参寄存器为AX

self.Emit("SUBQ", arch.Imm(_FP_size), _SP) // SUBQ $_FP_size, SPself.Emit("MOVQ", _BP, arch.Ptr(_SP, _FP_offs)) // MOVQ BP, _FP_offs(SP)self.Emit("LEAQ", arch.Ptr(_SP, _FP_offs), _BP) // LEAQ _FP_offs(SP), BPself.Emit("MOVQ", _AX, _ARG_1) // MOVQ AX, rb<>+0(FP)self.Emit("MOVQ", _BX, _ARG_2) // MOVQ BX, vp<>+8(FP)

1) 压栈

热点函数在执行时会产生中间结果,将这些中间结果保存在栈中。需要在压栈时为中间结果预留存储空间。函数的栈空间如下:



2)保存入参

golang在早期为了支持跨平台,函数传参是通过压栈的方式,由于内存访问的速度慢于寄存器,这种传参方式会带来性能损耗。1.17版本之后,传参方式改为了寄存器传参。

对于1.17之后的版本,在调用热点函数的过程中,这几个寄存器会被复用,因此需要将入参压入栈中保存起来。

4.2 epologue

函数执行完成的收尾工作:还原BP;释放当前函数的栈空间;返回。

self.Emit("MOVQ", arch.Ptr(_SP, _FP_offs), _BP) // 还原BP指针self.Emit("ADDQ", arch.Imm(_FP_size), _SP) // 释放当前函数的占空间self.Emit("RET") // RET

热点函数拼装有几个关键的地方:暂存中间结果;获取下一个热点函数地址;参数传递。

暂存中间结果

plan9汇编需要调用者保存寄存器中的临时寄存结果,即所谓的caller-save。

中间结果可以保存在callee中不会使用到的寄存器中,但是为了防止误用,可以将临时结果保存在栈中。调用入口函数时压栈可以多压一段内存,在栈顶附近预留出来不,函数调用完成后再从内存中加载到寄存器。

获取热点函数的地址

使用汇编拼接热点函数时,需要获取热点函数的地址,asm2asm给出了一个方案:定义一个获取参考地址的函数_native_entry_,该函数返回自身的地址,并通过定义桩函数在Go代码中直接调用;在转换为plan9汇编时,计算每个热点函数相对于参考地址的偏移量offset,然后通过_native_entry_()+offset获取热点函数的地址。

由于获取函数地址需要执行一次函数调用,存在函数调用的开销,而函数的地址是固定的。因此可以在程序启动时获取一次地址记录到全局变量中,后续如果还需要获取函数的地址,直接读取全局变量即可。

字节的json库sonic中的实现是将热点函数的地址定义为由_native_entry_()+offset初始化的全局变量,这样在程序运行过程中,获取每个热点函数的地址只需要调用一次_native_entry_函数。

//go:nosplit//go:noescape//goland:noinspection ALLfunc __native_entry__() uintptrvar ( _subr__add = __native_entry__() + 32224 _subr__f32toa = __native_entry__() + 28496 _subr__f64toa = __native_entry__() + 752 ... )

我们可以进一步优化,定义一个_native__entry全局变量,并用_native_entry_()初始化,热点函数的地址定义为通过_native__entry+offset初始化的全局变量,这样在程序运行过程中,只需要调用一次_native_entry_(),就可以获取所有热点函数的地址。

//go:nosplit//go:noescape//goland:noinspection ALLfunc __native_entry__() uintptrvar ( _native__entry = __native_entry__() _subr__add = _native__entry + 32224 _subr__f32toa = _native__entry + 28496 _subr__f64toa = _native__entry + 752 ... )

参数传递

若需向热点函数传递参数,可将参数按照顺序赋值给DI、SI、DX等寄存器中。例如,向Add函数传递两个参数:

self.Emit("MOVQ", _ARG_1, _DI) // MOVQ AX, rb<>+0(FP)self.Emit("MOVQ", _ARG_2, _SI) // MOVQ BX, vp<>+8(FP)self.call(native.FuncAdd)

其中:

_ARG_1、_ARG_2为暂存在栈或者寄存器中的参数;_DI、_SI为DI和SI寄存器。

Emit、call为自行封装的函数,将参数转换为golang-asm中的数据结构(4.4会介绍)。

返回值保存在AX寄存器中。

4.4 在线汇编

在线汇编使用从Go的汇编代码中拷贝出来的库golang-asm。

一条汇编语句用obj.Prog结构体表示,包含指令和参数数据。 参数均需转化为obj.Addr结构,例如立即数表示为:

obj.Addr{Type: obj.TYPE_CONST,Offset: imm,}

Go汇编代码库中设置架构即可获取架构对应的指令和寄存器列表,如:

_AC = archassem.Set("amd64")

主要用于校验当前指令是否在架构中支持,若不支持可输出错误提示或直接panic。

每条汇编语句对应的数据结构obj Prog会被保存在一个链表中,然后将这个链表中的语句汇编。

4.5 减少在线汇编的开销

在线汇编存在开销,而大多数场景下,热点函数的组合是可重用的,即汇编结果是可重用的。可以使用缓存或者离线编译两种方法来减少在线汇编的次数。

4.5.1 缓存

对于可复用的汇编结果,缓存是一个比较容易想到的优化方法。

若热点函数的组合不确定,类似sonic这种通用的json库,可以参考其中的JIT(Just In Time)方案,即仅在需要时才执行开销巨大的汇编操作;并且将汇编结果缓存起来,再次需要时复用缓存的结果。缓存的结构体设计可参考sonic中的数组+hash。

若热点函数的组合数可控或基本确定,则可以使用更轻量级的实现,比如定义一个数组来保存各个组合对应的机器码的指针地址。

4.5.2 离线汇编

针对热点函数组合确定的场景,也可以更进一步优化,可以离线完成汇编操作,然后将机器码保存在文件中,或以常量的形式保存在二进制文件中,在服务运行时直接加载到内存执行。 例如:

var loader loader.Loaderloader = []byte{72,129,236,136,0,0,0,72,137,172,36,128,0,0,0,72,141,172,36,128,0,0,0,72,137,132,36,144,0,0,0,72,137,156,36,152,0,0,0,69,49,228,69,49,237,69,49,219,72,139,132,36,144,0,0,0,72,139,156,36,152,0,0,0,72,1,216,72,131,192,100,72,137,132,36,152,0,0,0,72,139,172,36,128,0,0,0,72,129,196,136,0,0,0,195} f := loader.Load("code", 1, 0)f1 := *(*funcs.SumFunc)(unsafe.Pointer(&f))

其中,Load函数的实现为将[]byte加载到堆中,并将对应的地址空间权限设置为可运行:

func (self Loader) LoadWithFaker(fn string, fp int, args int, faker interface{}) (f Function) {p := os.Getpagesize()n := (((len(self) - 1) / p) + 1) * p/* register the function */m := mmap(n)/* reference as a slice */s := *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{Data: m,Cap: n,Len: len(self),}))fmt.Println("fn:", fn, "; s:", self)/* copy the machine code, and make it executable */copy(s, self)mprotect(m, n)return Function(&m)}

本文考虑Go语言优化不足、不能使用SIMD指令的现状,为进一步优化性能,给出用C重写Go中的cpu密集型函数的一般方法。分别针对直接整个函数用C重写、动态组装热点函数两个场景,给出了重写的实现和代码示例。当go服务存在显著cpu瓶颈时,可以考虑使用本文中的方法优化。