0%

cgo 内存优化在 Envoy Go 的应用

cgo 内存优化合并之后,本来说,最近太卷了,等有空一点了,再把这个优化集成到 Envoy Go 里面去。

但是,看到这个优化,还比较的受欢迎,比如:golang-fips/openssl 已经用上了,tinygo 也增加了这个语法支持。

一想,作为始作俑者,咱也不能落后呀,于是又卷了一把自己,趁着周末搞了一把优化,具体见:
https://github.com/envoyproxy/envoy/pull/29396

优化实现

因为这个 cgo 这个优化,只是加了一些指令,帮助 cgo 编译器不再强制将参数 escape 到堆上,所以,我们只需要给一些 C 函数加上新的指令即可。

比如这些有将 Go 指针传入到 C 的:

1
2
3
4
#cgo noescape envoyGoFilterHttpCopyHeaders
#cgo nocallback envoyGoFilterHttpCopyHeaders
#cgo noescape envoyGoFilterHttpSetHeaderHelper
#cgo nocallback envoyGoFilterHttpSetHeaderHelper

不过,还是有两个小的点可以分享一下的。

版本兼容性

因为新增的指令在 1.21 之前是没有的,老版本编译器看到这些会报错,所以,我们需要针对 1.22 才生效。

所以,单独搞了个文件,并加了上编译指令:

1
//go:build go1.22

slice 内存优化

在获取 Header 的 API 中,我们是预先在 Go 侧申请内存,也就是通过 make slice 的方式。

如果我们想让这个内存也留在栈上,除了加上新的指令,还有需要一个改动。

原因是 make slice 的时候,如果长度是一个变量,那么 Go 编译器就会将这个 slice 给 escape 到堆上,因为函数栈空间大小是编译期间就计算好的,没法动态算的。

所以,我们这里取舍一下:

1
2
3
4
5
if num <= maxStackAllocedHeaderSize {
strs = make([]string, maxStackAllocedSliceLen)
} else {
strs = make([]string, num*2)
}

对于大部分 header 数量少于 maxStackAllocedHeaderSize 的,则直接使用栈空间,如果超过了,则还是动态申请,用堆上内存。

这个 maxStackAllocedHeaderSize 太大了也不好,因为是函数栈空间大小是预先计算的,每次执行函数都会预先准备的,虽然相对开销低,但是也不是完全零成本,太大了浪费成本也不能忽视。

所以,现在目前是拍脑袋定的 16,没有仔细的调研/压测过,以后有空再对比下的,哈哈

不过,这种值也只能是尽量适用于大多数场景了,没法完全通用的了。好在这只是很小的一个优化,在多数情况下,其实对整体影响并不大。

优化效果

说实话,我们对这个效果是有预期的,铁定高不了多少。

之前搞的 cgo CPU 优化,那是每次 C 调用 Go 能提升 10+ 倍,减少 1000+ ns,在压测的时候能让简单场景下的 qps 提升约 10%。

而这次的内存优化,在上一篇介绍过单个 string 指针参数的 benchmark,只能提升约 20%,减少 20+ ns,这个量级差距还是很明显的。

这次的压测场景是,在 DecodeHeaderEncodeHeader 都有类似的逻辑:获取 Header 和设置 header,具体可见这里的代码:
https://github.com/doujiang24/envoy-filter-benchmark/tree/main/golang-header-get-set

还是上一次压测的环境,阿里云 2c4g,加 wrk 压测。

QPS 提升约 0.5-1.0%,GC 次数减少约 50%

怎么说呢,虽然确实不太明显,但是很符合预期

网关这种场景里,有很大部分的基础开销是 proxy 的基础逻辑,比如接受网络请求,发送网络请求。要搞一些底层的优化,对整体有较大提升的,其实还是比较难的了。