标准库中函数大多数情况下更通用,性能并非最好的,还是不能过于迷信标准库,最近又有了新发现,strings.Replace
这个函数自身的效率已经很好了,但是在特定情况下效率并不是最好的,分享一下我如何优化的吧。
我的服务中有部分代码使用 strings.Replace
把一个固定的字符串删除或者替换成另一个字符串,它们有几个特点:
- 旧的字符串大于或等于新字符串
(len(old) >= len(new)
- 源字符串的生命周期很短,替换后就不再使用替换前的字符串
- 它们都比较大,往往超过 2k~4k
本博文中使用函数均在 go-extend 中,优化后的函数在 exbytes.Replace 中。
发现问题
近期使用 pprof
分析内存分配情况,发现 strings.Replace
排在第二,占 7.54%
, 分析结果如下:
1 | go tool pprof allocs |
标准库中最常用的函数,居然……,不可忍必须优化,先使用 list strings.Replace
看一下源码什么地方分配的内存。
1 | (pprof) list strings.Replace |
从源码发现首先创建了一个 buffer
来起到缓冲的效果,一次分配足够的内存,这个在之前 【Go】slice的一些使用技巧 里面有讲到,另外一个是 string(t[0:w])
类型转换带来的内存拷贝,buffer
能够理解,但是类型转换这个不能忍,就像凭空多出来的一个数拷贝。
既然类型转换这里有点浪费空间,有没有办法可以零成本转换呢,那就使用 go-extend 这个包里面的 exbytes.ToString
方法把 []byte
转换成 string
,这个函数可以零分配转换 []byte
到 string
。 t
是一个临时变量,可以安全的被引用不用担心,一个小技巧节省一倍的内存分配,但是这样真的就够了吗?
我记得 bytes
标准库里面也有一个 bytes.Replace
方法,如果直接使用这种方法呢就不用重写一个 strings.Replace
了,使用 go-extend 里面的两个魔术方法可以一行代码搞定上面的优化效果 s = exbytes.ToString(bytes.Replace(exstrings.UnsafeToBytes(s), []byte{' '}, []byte{''}, -1))
, 虽然是一行代码搞定的,但是有点长,exstrings.UnsafeToBytes
方法可以极小的代价把 string
转成 bytes
, 但是 s
不能是标量或常量字符串,必须是运行时产生的字符串否者可能导致程序奔溃。
这样确实减少了一倍的内存分配,即使只有 47.46GB
的分配也足以排到前十了,不满意这个结果,分析代码看看能不能更进一步减少内存分配吧。
分析代码
使用火焰图看看究竟什么函数在调用 strings.Replace
呢:
这里主要是两个方法在使用,当然我记得还有几个地方有使用,看来不在火焰图中应该影响比较低 ,看一下代码吧(简化的代码不一定完全合理):
1 | // 第一部分 |
通过分析我们发现前两个主要是为了删除一个字符串,第三个是为了把一个字符串替换为另一个字符串,并且源数据的生命周期很短暂,在执行替换之后就不再使用了,能不能原地替换字符串呢,原地替换的就会变成零分配了,尝试一下吧。
优化
先写一个函数简单实现原地替换,输入的 len(old) < len(new)
就直接调用 bytes.Replace
来实现就好了 。
1 | func Replace(s, old, new []byte, n int) []byte { |
写个性能测试看一下效果:
1 | go test -bench="." -run=nil -benchmem |
使用这个新的函数和 bytes.Replace
对比,内存分配是少了,但是性能却下降了那么多,崩溃…. 啥情况呢,对比 bytes.Replace
的源码发现我这个代码里面 s = append(s[:i], s[i+len(old)-len(new):]...)
每次都会移动剩余的数据导致性能差异很大,可以使用 go test -bench="." -run=nil -benchmem -cpuprofile cpu.out -memprofile mem.out
的方式来生成 pprof
数据,然后分析具体有问题的地方。
找到问题就好了,移动 wid
之前的数据,这样每次移动就很少了,和 bytes.Replace
的原理类似。
1 | func Replace(s, old, new []byte, n int) []byte { |
在运行一下性能测试吧:
1 | go test -bench="." -run=nil -benchmem |
运行性能差不多,而且更好了,内存分配也减少,不是说是零分配吗,为啥有一次分配呢?
1 | var replaces string |
可以看到使用了 []byte(replaces)
做了一次类型转换,因为优化的这个函数是原地替换,执行过一次之后后面就发现不用替换了,所以为了公平公正两个方法每次都转换一个类型产生一个新的内存地址,所以实际优化后是没有内存分配了。
之前说写一个优化 strings.Replace
函数,减少一次内存分配,这里也写一个这样函数,然后增加两个性能测试函数,对比一下效率 性能测试代码:
1 | go test -bench="." -run=nil -benchmem |
运行效率上都相当,优化之后的 UnsafeStringsReplace
函数减少了一次内存分配只有一次,和 bytes.Replace
相当。
修改代码
有了优化版的 Replace
函数就替换到项目中吧:
1 | // 第一部分 |
上线后性能分析
1 | go tool pprof allocs2 |
居然在 allocs
上居然找不到了,确实是零分配。
优化前 profile
:
1 | go tool pprof profile |
优化后 profile
:
1 | go tool pprof profile2 |
通过 profile
来分配发现性能也有一定的提升,本次 strings.Replace
和 bytes.Replace
优化圆满结束。
本博文中使用函数均在 go-extend 中,优化后的函数在 exbytes.Replace 中。