怎么用c(【后台技术】用C重写Go中cpu密集型函数的一般方法)

Posted

篇首语:知识给世界带来光明,知识给人类增长财富。本文由小常识网(cha138.com)小编为大家整理,主要介绍了怎么用c(【后台技术】用C重写Go中cpu密集型函数的一般方法)相关的知识,希望对你有一定的参考价值。

怎么用c(【后台技术】用C重写Go中cpu密集型函数的一般方法)

作者:pengkou,腾讯IEG后台开发工程师

| 导语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 inttype GoSlice struct     Ptr unsafe.Pointer    Len int    Cap int

2.2 一些高性能C代码的方法

既然要用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

4.1 prologue

    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)保存入参

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

4.3 热点函数拼装

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

暂存中间结果

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.Loader	loader = []byte72,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瓶颈时,可以考虑使用本文中的方法优化。

相关参考

模块变量(为 Python 写一个 C++ 扩展模块)

...C语言编写Python模块。用C语言编写Python模块允许你将计算密集型代码转移到C,同时保留Python的易用性。在本文中,我将向你展示如何编写一个C++扩展模块。使用C++而不是C,因为大多数编译器通常都能理解这两种

tan的平方等于什么

...1-sin^2θ)=(1-cos^2θ)/cos^2θ。  tan的平方等于什么  三角函数是数学中属于初等函数中的超越函数的一类函数,tan指的是正切。在Rt△ABC(直角三角形)中,∠C=90°,AB是∠C的对边c,B

松下伺服err340(系统调用的实现细节(用户态))

....26.9000。假如我们写了一个应用程序test.c如下所示:fork的函数申明在/usr/include/unistd.h头文件中,如下所示:我们知道gcc会在编译完成test.c后,然后链接libc.so动态库中的fork,所以fork的

tan330度等于多少

  具体计算函数值过程如下:  tan330°  =tan(360°-30°)  =-tan30°  =-√3/3  在Rt△ABC(直角三角形)中,∠C=90°,AB是∠C的对边c,BC是∠A的对边a,AC是∠B的对边b,正切函数就是tanB=b/a,即tanB=AC/BC。  在平面三角形中,...

tan37度°等于多少

...,AB是∠C的对边c,BC是∠A的对边a,AC是∠B的对边b,正切函数就是tanA=a/b,即tanA=BC/AC。正切的常用值一般为tan30°,tan45°,tan60°等等。  因此,将tan37°化为小数大约等于0.75355405,用分数表示大约等于3/4。

初中数学三角函数

...方a2+b2=c2。2、在Rt△ABC中,∠C为直角,则∠A的锐角三角函数为(∠A可换成∠B)。3、任意锐角的正弦值等于它的余角的余弦值;任意锐角的余弦值等于它的余角的正弦值。4、任意锐角的正切值等于它的余角的余切值;任意锐角的...

怎么看产品型号(DIY从入门到放弃:用CPU主频判断性能)

我们都知道CPU和显卡的性能决定了整机的游戏性能,但是在面对众多型号的产品时却会束手无策,不少玩家就把目光注意在了CPU的主频上。但CPU的主频又有默频和睿频之分,新的CPU默频甚至降到了2.5GHz,难道是性能缩水了?CPU的...

cos30°等于

  cos30°=√3/2。  cos是三角函数中的余弦函数,由余弦英文cosine简写而成。在Rt△ABC(直角三角形)中,∠C=90°,∠A的余弦是它的邻边比三角形的斜边,即cosA=b/c,也可写为cosa=AC/AB。余弦函数表示为:f(x)=cosx(x∈R)。  余弦定...

模拟量输出模块(西门子PLC S7-300的CPU、输入输出模块及其模拟量的处理)

S7-300CPU的分类1.紧凑型CPU:CPU312C,313C,313C-PtP,313C-2DP,314C-PtP和314C-2DP。各CPU均有计数、频率测量和脉冲宽度调制功能。有的有定位功能,有的带有I/O。2.标准型CPU:CPU312,CPU313,314,315,315-2DP和316-2DP。3.户外型CPU:CPU312IFM...

数字量模块DI和DO是什么意思(西门子PLC S7-300的CPU、输入输出模块及其模拟量的处理)

S7-300CPU的分类1.紧凑型CPU:CPU312C,313C,313C-PtP,313C-2DP,314C-PtP和314C-2DP。各CPU均有计数、频率测量和脉冲宽度调制功能。有的有定位功能,有的带有I/O。2.标准型CPU:CPU312,CPU313,314,315,315-2DP和316-2DP。3.户外型CPU:CPU312IFM...