前言
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 |
2.2 一些高性能 C 代码的方法
既然要用 C 重写热点函数,则有必要给出一些写出高性能 C 代码的方法。考虑通用性,这里列出一些非业务逻辑、算法相关的几种可以提高性能的方法。
1)loop unrolling
loop unrolling 是一种减少循环退出判断操作的方法,比如下面的代码片段:
可以通过 loop unrolling 方法修改为:
将 i<100 的执行次数从 101 次减少到 21 次。
缺点:
loop unrolling 会导致代码膨胀,从而增加内存开销,如果是服务端场景,增加的内存开销是微不足道的。
2)SIMD
#pragma simd
intrinsics 指令的示例如下,一次执行 8 个 float 值的加法。
这里不展开几种指令集下的函数列表和用法,详见。
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 汇编
这里示例的参数为 ENABLE_AVX2,即 AVX2 指令集。编译时需要编译多次,生成每个指令集的汇编文件,Go 程序启动时根据指令集选择使用的文件。
2.4.2 转化成 plan9 汇编
Go 使用的汇编为 plan9 汇编,而 clang 编译出来的为 x86 汇编,需要转化为 plan9 汇编。
本文在 3 和 4 分别给出直接调用和热点函数组装两种调用方式:直接调用使用直接转换的 plan9 汇编文件即可;组合调用的方式需要获取每个热点函数的地址,基于函数调用开销考虑,参考字节的使用另一个转换工具。
3 直接调用
直接调用 C 编译出来的汇编代码,需要先将 x86 汇编转换为 plan9 汇编,然后使用桩函数调用即可。
3.1 示例目录结构
可以参考下面的示例目录结构来组织代码:
其中:native 为 C 文件、桩函数和转换的工作目录;lib 为 go 程序运行时使用的热点函数目录。目录内各个文件的含义见上面的注释。
为 c2goasm 依赖的库,需要安装并将安装目录添加到 PATH 环境变量中。
3.2 定义桩函数
Go 调用汇编需要定义与汇编函数定义相同的桩函数,并使用指针类型的入参传参。
例如如下 C 代码:
对应的桩函数为:
其中,_Add 为桩函数定义。桩函数通过指针传递返回值,为了更方便调用,可以在封装 export 的函数 Add 时修改为通过返回值传递返回值。
3.3 转换成 plan9 汇编
使用将 C 语言直接编译出来的 x86 汇编转化为 plan9 汇编。
其中,示例文件 add.s 为 x86 汇编文件,add_amd64.s 为转换后的 plan9 汇编文件。需要注意的是,_amd64 文件名后缀是必须的。
3.4 拷贝到运行时目录
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
4.1 prologue
- 压栈
热点函数在执行时会产生中间结果,将这些中间结果保存在栈中。需要在压栈时为中间结果预留存储空间。函数的栈空间如下:
2)保存入参
golang 在早期为了支持跨平台,函数传参是通过压栈的方式,由于内存访问的速度慢于寄存器,这种传参方式会带来性能损耗。1.17 版本之后,传参方式改为了寄存器传参。
对于 1.17 之后的版本,在调用热点函数的过程中,这几个寄存器会被复用,因此需要将入参压入栈中保存起来。
4.2 epologue
函数执行完成的收尾工作:还原 BP;释放当前函数的栈空间;返回。
4.3 热点函数拼装
热点函数拼装有几个关键的地方:暂存中间结果;获取下一个热点函数地址;参数传递。
暂存中间结果
plan9 汇编需要调用者保存寄存器中的临时寄存结果,即所谓的 caller-save。
中间结果可以保存在 callee 中不会使用到的寄存器中,但是为了防止误用,可以将临时结果保存在栈中。调用入口函数时压栈可以多压一段内存,在栈顶附近预留出来不,函数调用完成后再从内存中加载到寄存器。
获取热点函数的地址
使用汇编拼接热点函数时,需要获取热点函数的地址,给出了一个方案:定义一个获取参考地址的函数native_entry,该函数返回自身的地址,并通过定义桩函数在 Go 代码中直接调用;在转换为 plan9 汇编时,计算每个热点函数相对于参考地址的偏移量 offset,然后通过native_entry()+offset 获取热点函数的地址。
由于获取函数地址需要执行一次函数调用,存在函数调用的开销,而函数的地址是固定的。因此可以在程序启动时获取一次地址记录到全局变量中,后续如果还需要获取函数的地址,直接读取全局变量即可。
字节的 json 库中的实现是将热点函数的地址定义为由native_entry()+offset 初始化的全局变量,这样在程序运行过程中,获取每个热点函数的地址只需要调用一次native_entry函数。
我们可以进一步优化,定义一个native__entry 全局变量,并用_native_entry()初始化,热点函数的地址定义为通过native__entry+offset 初始化的全局变量,这样在程序运行过程中,只需要调用一次_native_entry(),就可以获取所有热点函数的地址。
参数传递
若需向热点函数传递参数,可将参数按照顺序赋值给 DI、SI、DX 等寄存器中。例如,向 Add 函数传递两个参数:
其中:
_ARG_1、_ARG_2 为暂存在栈或者寄存器中的参数;_DI、_SI 为 DI 和 SI 寄存器。
Emit、call 为自行封装的函数,将参数转换为中的数据结构(4.4 会介绍)。
返回值保存在 AX 寄存器中。
4.4 在线汇编
在线汇编使用从 Go 的汇编代码中拷贝出来的库。
一条汇编语句用 obj.Prog 结构体表示,包含指令和参数数据。 参数均需转化为 obj.Addr 结构,例如立即数表示为:
Go 汇编代码库中设置架构即可获取架构对应的指令和寄存器列表,如:
主要用于校验当前指令是否在架构中支持,若不支持可输出错误提示或直接 panic。
条汇编语句对应的数据结构 obj.Prog 会被保存在一个链表中,这个链表汇编。
4.5 减少在线汇编的开销
在线汇编存在开销,而大多数场景下,热点函数的组合是可重用的,即汇编结果是可重用的。可以使用缓存或者离线编译两种方法来减少在线汇编的次数。
4.5.1 缓存
对于可复用的汇编结果,缓存是一个比较容易想到的优化方法。
若热点函数的组合不确定,类似 sonic 这种通用的 json 库,可以参考其中的 JIT(Just In Time)方案,即仅在需要时才执行开销巨大的汇编操作;并且将汇编结果缓存起来,再次需要时复用缓存的结果。缓存的结构体设计可参考 sonic 中的数组+hash。
若热点函数的组合数可控或基本确定,则可以使用更轻量级的实现,比如定义一个数组来保存各个组合对应的机器码的指针地址。
4.5.2 离线汇编
针对热点函数组合确定的场景,也可以更进一步优化,可以离线完成汇编操作,然后将机器码保存在文件中,或以常量的形式保存在二进制文件中,在服务运行时直接加载到内存执行。 例如:
其中,Load 函数的实现为将[]byte 加载到堆中,并将对应的地址空间权限设置为可运行:
5 总结
本文考虑 Go 语言优化不足、不能使用 SIMD 指令的现状,为进一步优化性能,给出用 C 重写 Go 中的 cpu 密集型函数的一般方法。分别针对直接整个函数用 C 重写、动态组装热点函数两个场景,给出了重写的实现和代码示例。当 go 服务存在显著 cpu 瓶颈时,可以考虑使用本文中的方法优化。
参考资料