Go语言中将切片长度存入变量为何比直接调用len()慢很多?
我编写了用于接收已排序int类型切片、返回去重结果切片的函数uniq,实现如下:
func uniq(x []int) []int { i := 0 for i < len(x)-1 { if x[i] == x[i+1] { copy(x[i:], x[i+1:]) x = x[:len(x)-1] } else { i++ } } return x }
基于相同逻辑,我重写了功能、返回结果完全一致的版本uniq2,实现如下:
func uniq2(x []int) []int { i := 0 l := len(x) for i < l-1 { if x[i] == x[i+1] { copy(x[i:], x[i+1:]) l-- } else { i++ } } return x[:l] }
两个函数的唯一差异为:uniq2没有在每次迭代时对切片x做截断、直接访问len(x),而是提前将len(x)的值存入变量l,每次移位切片元素后对l执行自减操作,最终返回x[:l]。
我原本预期uniq2不需要每次迭代都计算len(x),性能应该略优于uniq,但实际测试中它的运行速度却慢得多。
测试环境与方法
我在Linux环境下开展性能测试:先生成随机已排序切片,循环调用uniq/uniq2共1000次,测试代码如下:
func main() { rand.Seed(time.Now().Unix()) for i := 0; i < 1000; i++ { _ = uniq(genSlice()) //_ = uniq2(genSlice()) } } func genSlice() []int { x := make([]int, 0, 1000) for num := 1; num <= 10; num++ { amount := rand.Intn(1000) for i := 0; i < amount; i++ { x = append(x, num) } } return x }
执行测试的命令如下:
$ go build uniq.go $ time ./uniq
测试结果显示:uniq的运行耗时通常为5-6秒,而uniq2耗时达12-15秒,运行速度不足前者的1/2。
原因解析
首先要纠正一个认知误区:Go中读取len(x)几乎没有开销。切片的本质是一个包含指针、长度、容量的三字段结构体,len(x)就是直接读取结构体里存储长度的整数字段,和读取自定义局部变量l的开销完全一致,不存在“每次迭代计算len”的额外成本。
uniq2性能暴跌的核心原因是每次copy操作都执行了巨量的无效内存拷贝:
uniq每次删除重复元素后都会通过x = x[:len(x)-1]截断切片,切片的实时长度永远等于当前有效元素个数。执行copy(x[i:], x[i+1:])时,源切片x[i+1:]的长度刚好是需要前移的有效元素总数,copy只会拷贝这部分必要数据。uniq2全程没有修改切片x的长度,x的长度始终是传入时的原始长度。Go内置copy的拷贝长度由源、目标切片的实际长度决定,此时执行copy(x[i:], x[i+1:]),会把从i+1位置一直到切片原始末尾的所有元素全部前移一位——这其中包含了大量已经在逻辑长度l之外、完全不需要处理的无效数据。
举个直观例子:如果传入切片原始长度为1000,当去重到只剩500个有效元素时,uniq每次copy仅需拷贝500-i-1个元素,而uniq2每次copy仍然要拷贝1000-i-1个元素,平白多做了近一倍的内存搬运工作,耗时自然达到前者的两倍以上。
除此之外还有次要的性能影响:uniq中切片长度和有效长度完全一致,编译器可以很容易完成边界检查消除(BCE),去掉数组访问、copy操作时的越界判断逻辑;而uniq2的有效长度存在局部变量l中,编译器很难证明所有对x的访问都在l范围内,会保留大量边界检查指令,进一步拉低运行速度。
内容的提问来源于stack exchange,提问作者ban_javascript

