前言
Go由于简单易学、语言层面支持并发、容易部署等优点,越来越受到大家的欢迎 。但是由于某些原因,Go还没有提供语言级的SIMD函数,编译优化也没有Clang等其他编译器做得更深入 , 因此在某些考虑性能或成本的场景下,C/C++更具优势 。本人之前研究了字节的高性能库sonic,借鉴其中使用C重写热点函数的思路 , 另外考虑直接调用用C重写的函数的场景,给出使用C重写Go中cpu密集型函数的一般方法 。
1 分析程序中是否存在cpu热点
【【后台技术】用C重写Go中cpu密集型函数的一般方法】首先分析服务中cpu操作热点分布,查看是否存在优化的必要 。如果没有明显的cpu热点函数,则没有必要引入本文的方法引入开发编译的复杂度 。
1)使用工具分析
可以使用工具如pprof,Go的性能分析工具trace来分析cpu热点,相关的资料比较多 , 这里不再赘述 。
2)明显的cpu密集操作
如果存在大数据量的向量操作考勤系统的c语言源代码,则可以使用文中的方法优化 。
2 使用C编写热点函数
为什么不使用cgo
调用C函数的时候,必须切换当前的栈为线程的主栈,这带来了两个比较严重的问题:
线程的栈在Go运行时是比较少的,受到P/M数量的限制 , 一般可以简单的理解成受到限制; 由于需要同时保留C/C++的运行时 , CGO需要在两个运行时和两个ABI(抽象二进制接口)之间做翻译和协调 。这就带来了很大的开销 。
2.1 与C类型转换
Go与C数据类型对照表
go类型
c类型
.
void *
int
type GoString struct {Ptr unsafe.PointerLen int}type GoSlice struct {Ptr unsafe.PointerLen intCap int}
2.2 一些高性能C代码的方法
既然要用C重写热点函数,则有必要给出一些写出高性能C代码的方法 。考虑通用性,这里列出一些非业务逻辑、算法相关的几种可以提高性能的方法 。
1)loop
loop 是一种减少循环退出判断操作的方法,比如下面的代码片段
int sum = 0;for (unsigned int i = 0; i < 100; i++) {sum += i;}
可以通过loop 方法修改为
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
缺点:
loop 会导致代码膨胀,从而增加内存开销,如果是服务端场景,增加的内存开销是微不足道的 。
2)SIMD
SIMD是Data的缩写,即单指令流多数据流,同时对多个数据执行相同的操作 。使用SIMD有几种方法,比如使用Intel提供的封装了SIMD的库、借助编译器自动向量化、有的编译器(如Cilk)支持的编译器指示符# simd强制将循环向量化、使用内置函数 。
指令的示例如下 , 一次执行8个float值的加法 。
intmain(){ __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);}
这里不展开几种指令集下的函数列表和用法,详见IntelGuide 。
3)减少cache miss
一起使用的函数声明定义在一起; 一起使用的变量存储在一起,通过结构体的方式整理到一起,或者定义为局部变量 。变量尽可能的在靠近第一次使用的位置声明和定义,即就近原则( of ) 。动态申请的变量是cache不友好的,如stl容器、,可以的话避免使用 。
4)减少函数调用开销
小函数使用内联
使用迭代而不是递归
5)减少分支
使用计算减少分支
长的if else改成
出现概率更高条件放在前面
6)
这里指的是将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
这里示例的参数为,即AVX2指令集 。编译时需要编译多次,生成每个指令集的汇编文件,Go程序启动时根据指令集选择使用的文件 。
2.4.2 转化成plan9汇编
Go使用的汇编为plan9汇编,而clang编译出来的为x86汇编,需要转化为plan9汇编 。
本文在3和4分别给出直接调用和热点函数组装两种调用方式:直接调用使用直接转换的plan9汇编文件即可;组合调用的方式需要获取每个热点函数的地址 , 基于函数调用开销考虑 , 参考字节的sonic使用另一个转换工具 。
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目录下的桩函数和汇编代码的脚本 , 包含编译 , 汇编转换,拷贝等操作
其中:为C文件、桩函数和转换的工作目录;lib为go程序运行时使用的热点函数目录 。目录内各个文件的含义见上面的注释 。
为依赖的库,需要安装并将安装目录添加到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为桩函数定义 。桩函数通过指针传递返回值,为了更方便调用 , 可以在封装的函数Add时修改为通过返回值传递返回值 。
3.3 转换成plan9汇编
使用将C语言直接编译出来的x86汇编转化为plan9汇编 。
./c2goasm -a add.s add_amd64.s
其中,示例文件add.s为x86汇编文件,.s为转换后的plan9汇编文件 。需要注意的是,文件名后缀是必须的 。
3.4 拷贝到运行时目录
将目录中生成的plan9汇编和桩函数拷贝到运行目录lib中cp * ../lib 。
4 组合调用
如果一次函数使用到多个热点函数,则需要将这些热点函数组合起来 。
组合拼接的代码是汇编指令 , 因此本章先介绍一些汇编的基本知识 , 然后介绍怎么将多个热点函数拼接起来 。
需要说明的是:手写汇编是非常不推荐的,原因是首先比较难写,容易出错,另外不能利用编译器的优化能力 , 写出的代码效率不一定最优 。
4.0 go汇编简介(plan9汇编)
入参
1.17版本之后函数调用是通过寄存器传参的,按照参数的顺序 , 分别赋值给AX、BX、CX、DI等寄存器 。文中后面的代码以1.17以后得版本为例 。
汇编函数入参
热点函数的入参为DI、SI、DX等寄存器 。调用汇编函数之前需要将参数按照顺序写入这几个寄存器之中 。
这里需要注意的是,plan9汇编为-save , 如果中使用了当前保存暂存结果寄存器 , 寄存器中的值需要保存到其他寄存器或者栈中 。
出参
出参寄存器为AX
4.1
self.Emit("SUBQ", arch.Imm(_FP_size), _SP)// SUBQ $_FP_size, SP self.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), BP self.Emit("MOVQ", _AX, _ARG_1)// MOVQ AX, rb+0(FP) self.Emit("MOVQ", _BX, _ARG_2)// MOVQ BX, vp+8(FP)
1) 压栈
热点函数在执行时会产生中间结果,将这些中间结果保存在栈中 。需要在压栈时为中间结果预留存储空间 。函数的栈空间如下:
2)保存入参
在早期为了支持跨平台,函数传参是通过压栈的方式,由于内存访问的速度慢于寄存器,这种传参方式会带来性能损耗 。1.17版本之后,传参方式改为了寄存器传参 。
对于1.17之后的版本 , 在调用热点函数的过程中 , 这几个寄存器会被复用 , 因此需要将入参压入栈中保存起来 。
4.2
函数执行完成的收尾工作:还原BP;释放当前函数的栈空间;返回 。
self.Emit("MOVQ", arch.Ptr(_SP, _FP_offs), _BP) // 还原BP指针 self.Emit("ADDQ", arch.Imm(_FP_size), _SP)// 释放当前函数的占空间 self.Emit("RET")// RET
4.3 热点函数拼装
热点函数拼装有几个关键的地方:暂存中间结果;获取下一个热点函数地址;参数传递 。
暂存中间结果
plan9汇编需要调用者保存寄存器中的临时寄存结果,即所谓的-save 。
中间结果可以保存在中不会使用到的寄存器中 , 但是为了防止误用 , 可以将临时结果保存在栈中 。调用入口函数时压栈可以多压一段内存 , 在栈顶附近预留出来不,函数调用完成后再从内存中加载到寄存器 。
获取热点函数的地址
使用汇编拼接热点函数时,需要获取热点函数的地址,给出了一个方案:定义一个获取参考地址的函数,该函数返回自身的地址 , 并通过定义桩函数在Go代码中直接调用;在转换为plan9汇编时 , 计算每个热点函数相对于参考地址的偏移量,然后通过()+获取热点函数的地址 。
由于获取函数地址需要执行一次函数调用,存在函数调用的开销,而函数的地址是固定的 。因此可以在程序启动时获取一次地址记录到全局变量中,后续如果还需要获取函数的地址 , 直接读取全局变量即可 。
字节的json库sonic中的实现是将热点函数的地址定义为由()+初始化的全局变量,这样在程序运行过程中,获取每个热点函数的地址只需要调用一次函数 。
//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...)
我们可以进一步优化,定义一个全局变量,并用()初始化 , 热点函数的地址定义为通过+初始化的全局变量,这样在程序运行过程中,只需要调用一次(),就可以获取所有热点函数的地址 。
//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)
其中:
、为暂存在栈或者寄存器中的参数;_DI、_SI为DI和SI寄存器 。
Emit、call为自行封装的函数,将参数转换为-asm中的数据结构(4.4会介绍) 。
返回值保存在AX寄存器中 。
4.4 在线汇编
在线汇编使用从Go的汇编代码中拷贝出来的库-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 离线汇编
针对热点函数组合确定的场景,也可以更进一步优化,可以离线完成汇编操作,然后将机器码保存在文件中,或以常量的形式保存在二进制文件中考勤系统的c语言源代码,在服务运行时直接加载到内存执行 。例如:
var loader loader.Loader loader = []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)}
5 总结
本文考虑Go语言优化不足、不能使用SIMD指令的现状,为进一步优化性能,给出用C重写Go中的cpu密集型函数的一般方法 。分别针对直接整个函数用C重写、动态组装热点函数两个场景 , 给出了重写的实现和代码示例 。当go服务存在显著cpu瓶颈时,可以考虑使用本文中的方法优化 。
本文到此结束,希望对大家有所帮助 。
- C语言/C++编程,烟花表白程序
- ?为什么体操服上是国徽
- ?万达imax3d会发眼镜吗 会,一般的3d影院都会配发3d眼睛。3d眼镜大多数采用了
- ?甜玉米煮多久
- ?廖野天中国好声音名次廖野天赵家豪battle谁晋级了
- ?越南为什么不参加冬奥会
- ?元宵节的月亮是圆的吗
- ?鱼子酱罐头能直接吃吗
- ?油腻大叔的标配是什么
- 低gl是什么意思啊