[译]Go数组访问的优化
今天看了一篇很有趣的博文,讲到了使用Go写代码时,对于更新数组,一个小小的改动却能大大提高代码的效率。而自己平时最经常使用的居然是效率最低的一种方式。不得不感慨Go虽然入门容易,但是细节无处不在,平时需要注意的地方,需要深挖的地方太多太多。这里把文章翻译撸一下给大家分享下,文章很短,可能只需要你马桶上5分钟的时间,你就将Get一个很牛逼的技能。
只撸具体流程,需要看详细原文的,请移步:
原文地址:How the Go language improves expressiveness without sacrificing runtime performance
前提
假如你有一个数据结构,用于一系列的计数,数据结构如下:
type E struct {
A, B, C, D int
}
var e = make([]E, 1000)
然后你需要一个方法来更新数组e
中的计数。
最常用的方法
这个问题so easy,大家一拍脑袋就能想到的就是:无非就是一个循环便利数组,然后更新就可以了。然后有了下面的方法(为了大家能够快速理解,将原文代码稍有更改,后面示例同):
func BenchmarkManual(b *testing.B) {
var e = make([]E, SIZE)
for j := 0; j < b.N; j++ {
for i := range e {
e[i].A += 1
e[i].B += 2
e[i].C += 3
e[i].D += 4
}
}
}
这种方法有个问题:大家知道面向对象编程中为了让码农能将更多的精力集中在业务上,对数据类型进行了完美的封装。类似的,对应到这里就是你在访问一个数组元素时,不必考虑数组索引是否越界,编译器这里会自动在每次访问
e[i]
之前塞一个边界检查进去(这里并不是面向对象式的在类中封装了边界检查,而是有编译器承担了相应的工作,但是思想类似。)。所以问题来了,这里每次循环里面更新一个e[i]
的元素时,需要访问四次e[i]
就有4次边界检查,有3次都是多余的。
改进
那么已经知道问题在哪里了,优化就很好做了,只用干掉那三次多余的隐藏的边界检查就可以了。
func BenchmarkUnroll(b *testing.B) {
var e = make([]E, SIZE)
for j := 0; j < b.N; j++ {
for i := range e {
v := &e[i]
v.A += 1
v.B += 2
v.C += 3
v.D += 4
}
}
}
这种方法取了e[i]
的地址,然后再做更新操作。所以只用做取地址那一次的边界检查即可。但是这种写法总有点怪怪的,别人看到可能要绕很大一个圈想v := &e[i]
这一步到底是啥意思,完全多此一举嘛。可能更有甚者还会纠结,为什么是取地址,然后直接操作地址,而不是操作地址的值呢…巴拉巴拉,总之怪怪的啦。
再改
好了,既然E
是一个结构体,我们给他封装一个方法试试:
func (e *E) update(a, b, c, d int) {
e.A += a
e.B += b
e.C += c
e.D += d
}
func BenchmarkUpdate(b *testing.B) {
var e = make([]E, SIZE)
for j := 0; j < b.N; j++ {
for i := range e {
e[i].update(1, 2, 3, 4)
}
}
}
这种方法因为update
声明的是属于(e *E)
,所以在执行e[i].update(1,2,3,4)
时,会自动塞如e[i]
的地址&e[i]
。同样只会做一次边界检查,所以这种方法和第二种有异曲同工之妙。只是因为封装了一层,函数调用涉及到栈的处理,效率不如第二种方法。
结果
执行benchmark测试,三种方法结果如下。可以看到第一种方法确实是性能最差的。第二种方法最好,第三种因为涉及到栈相关的操作,性能稍差。但是综合权衡代码可读性、可维护性和性能,第三种无疑是最好的。 还有貌似函数调用的压栈、出栈在这里性能上的损失很小,博主觉得从获得更好的代码可读性、可维护性这点损失完全可以忽略。
BenchmarkManual-4 300000 4714 ns/op
BenchmarkUnroll-4 1000000 1725 ns/op
BenchmarkUpdate-4 1000000 1884 ns/op