当前位置:首页 > 后端开发 > 正文内容

golang slice相关常见的功能优化手法

邻居的猫1个月前 (12-09)后端开发968

介绍一些开发中常用的slice相关的功能优化手法。鉴于golang编译器自身捉鸡的优化才干,优化的本钱就得分摊在开发者自己的头上了。

这篇文章会介绍的优化手法是下面这几样:

  1. 创立slice时预分配内存
  2. 操作slice前预分配内存
  3. slice表达式中合理设置cap值
  4. 增加多个零值元素的优化
  5. 循环打开
  6. 防止for-range仿制数据带来的损耗
  7. 鸿沟查看消除
  8. 并行处理slice
  9. 复用slice的内存
  10. 高效删去多个元素
  11. 减轻GC扫描压力

这篇文章不会评论缓存命中率和SIMD,我知道这两样也和slice的功能相关,但前者我认为是合格的开发者有必要要了解的,网上优异的教程也许多不需求我再赘述,后者除非功能瓶颈真的在数据吞吐量上不然一般不该该归入考虑规模尤其在go言语里,所以这两个主题本文不会介绍。

终究开篇之前我还想提示一下,功能瓶颈要靠测验和profile来定位,功能优化计划的收益和开支也需求功能测验来衡量,牢记不行生搬硬套。

本文比较长,所以我主张能够挑自己感爱好的内容看,有时间再通读。

本文索引

  • 创立slice时预分配内存
  • 操作slice前预分配内存
  • slice表达式中合理设置cap值
  • 向slice增加多个零值元素的优化
  • 循环打开
  • 防止for-ranges仿制数据带来的损耗
    • 防止仿制
    • 遍历字符串的时分防止转化带来的开支
  • BCE鸿沟查看消除
  • 并行处理slice
  • 复用
  • 高效删去多个元素
    • 删去全部元素
    • 删去头部或尾部的元素
    • 删去在中心方位的元素
  • 减轻GC扫描压力
  • 总结

创立slice时预分配内存

预分配内存是最常见的优化手法,我会分为创立时和运用中两部分来解说怎样进行优化。

提前为要创立的slice分配满足的内存,能够消除后续增加元素时扩容产生的功能损耗。

详细做法如下:

s1 := make([]T, 0, 预分配的元素个数)

// 另一种不太常见的预分配手法,此刻元素个数有必要是常量
var arr [元素个数]T
s2 := arr[:]

很简略的代码,功能测验我就不做了。

前面提到增加元素时扩容产生的功能损耗,这个损耗分为两方面,一是扩容需求从头核算slice的cap,尤其是1.19之后选用更平缓的分配战略后核算量是有所增加的,另一方面在于从头分配内存,假如没能原地扩容的话还需求从头分配一块内存把数据移动曩昔,再开释原先的内存,增加的元素越多遇到这种状况的概率越大,这是适当大的开支。

其他slice选用的扩容战略有时分会形成糟蹋,比方下面这样:

func main() {
    var a []int
    for i := 0; i < 2048; i++ {
            a = append(a, i)
    }
    fmt.Println(cap(a)) // go1.22: 2560
}

能够看到,咱们增加了2048个元素,但go终究给咱们分配了2560个元素的内存,糟蹋了将近500个。

不过预分配不是万金油,有约束了的适用场景:

适用场景:

  1. 清晰知道slice里会有多少个元素的场景
  2. 元素的个数尽管不确认,但大致在[x, y]的区间内,这时分能够挑选设置预分配巨细为y+N(N取决于差错规模,预分配许多内存之后再触发扩容的价值十分昂扬,所以算好差错规模宁可少数糟蹋也要防止再次扩容),当然x和y之间的差不能太大,像1和1000这种很显着是不该该进行预分配的,首要的判别依据是最坏状况下的内存糟蹋率。

除了上面两种状况,我不主张运用预分配,由于分配内存自身是要支付功能的价值的,不是上面两种场景时预分配都会不行防止的产生许多糟蹋,这些糟蹋带来的功能价值很或许会超越扩容的价值。

预分配内存还有另一个优点:假如分配的巨细是常量或许常量表达式,则有时机被逃逸剖析认定为巨细适宜分配在栈上,然后使功能更进一步进步。这也是编译器完成的,详细的代码如下:

// https://github.com/golang/go/blob/master/src/cmd/compile/internal/walk/builtin.go#L412

// walkMakeSlice walks an OMAKESLICE node.
func walkMakeSlice(n *ir.MakeExpr, init *ir.Nodes) ir.Node {
	l := n.Len
	r := n.Cap
	if r == nil {
		r = safeExpr(l, init)
		l = r
	}
	t := n.Type()
	if t.Elem().NotInHeap() {
		base.Errorf("%v can't be allocated in Go; it is incomplete (or unallocatable)", t.Elem())
	}
	if n.Esc() == ir.EscNone {
		if why := escape.HeapAllocReason(n); why != "" {
			base.Fatalf("%v has EscNone, but %v", n, why)
		}
		// 查看i是否是常量
		i := typecheck.IndexConst(r)
		if i < 0 {
			base.Fatalf("walkExpr: invalid index %v", r)
		}

		// 查看通往后创立slice暂时变量,分配在栈上
	}

	// 逃逸了,这时分会生成调用runtime.makeslice的代码
    // runtime.makeslice用mallocgc从堆分配内存
}

栈上分配内存速度更快,并且对gc的压力也更小一些,但目标会在哪被分配并不是咱们能操控的,咱们能做的也只要发明让目标分配在栈上的时机仅此而已。

操作slice前预分配内存

从slices包进入规范库开端,操作现有的slice时也能预分配内存了。

当然之前也能够,不过得绕些弯路,有爱好能够去看下slices.Grow是怎样做的。

经过简略的测验来看看作用:

func BenchmarkAppend(b *testing.B) {
	for i := 0; i < b.N; i++ {
		s := []int{1, 2, 3, 4, 5}
		for j := 0; j < 1024; j++ {
			s = append(s, j)
		}
	}
}

func BenchmarkAppendWithGrow(b *testing.B) {
	for i := 0; i < b.N; i++ {
		s := []int{1, 2, 3, 4, 5}
		s = slices.Grow(s, 1024)
		for j := 0; j < 1024; j++ {
			s = append(s, j)
		}
	}
}

这是成果,用benchstat进行了比较:

goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
         │   old.txt   │               new.txt               │
         │   sec/op    │   sec/op     vs base                │
Append-8   4.149µ ± 3%   1.922µ ± 5%  -53.69% (p=0.000 n=10)

         │    old.txt    │               new.txt                │
         │     B/op      │     B/op      vs base                │
Append-8   19.547Ki ± 0%   9.250Ki ± 0%  -52.68% (p=0.000 n=10)

         │  old.txt   │              new.txt               │
         │ allocs/op  │ allocs/op   vs base                │
Append-8   8.000 ± 0%   1.000 ± 0%  -87.50% (p=0.000 n=10)

不只速度快了一倍,内存也节省了50%,并且比较未用Grow的代码,优化往后的代码只需求一次内存分配。

功能进步的原因和上一节的彻底相同:防止了屡次扩容带来的开支。

一起节省内存的优点也和上一节相同是存在的:

func main() {
	s1 := make([]int, 10, 50) // 留意已经有必定的预分配了
	for i := 0; i < 1024; i++ {
		s1 = append(s1, i)
	}
	fmt.Println(cap(s1))  // 1280

	s2 := make([]int, 10, 50)
	s2 = slices.Grow(s3, 1024)
	for i := 0; i < 1024; i++ {
		s2 = append(s2, i)
	}
	fmt.Println(cap(s2))  // 1184
}

如比方所示,前者的内存运用率是80%,而后者是86.5%,Grow尽管也是运用append的机制来扩容,但它能够更充沛得运用内存,防止了糟蹋

也和上一节相同,运用前的预分配的适用场景也只要两个:

  1. 清晰知道会往slice里追加多少个元素的场景
  2. 追加的元素的个数尽管不确认,但大致在[x, y]的区间内,这时分能够挑选设置预分配巨细为y+N(和上面相同,N取决于差错规模)。

其他假如是拼接多个slice,最好运用slices.Concat,由于它内部会用Grow预分配满足的内存,比直接用append快一些。这也算本节所述优化手法的一个活得比方。

slice表达式中合理设置cap值

在比较新的go版别里slice表达式是能够有第三个参数的,即cap的值,方法相似:slice[start:end:capEnd]

留意我用了capEnd而不是cap,由于这个参数不是cap的长度,而是指新的slice最大能够拜访到原数组或许slice的(索引-1)的元素。举个比方:slice[1:2:3],这个表达式创立了一个新的切片,长度为2-1即1,能够拜访到原切片的索引3-1即2的元素,因而新切片能够拜访的元素实践上有index 1index 2两个,cap为2。

为啥要加这个参数呢?由于能够约束切片拜访的规模,防止意外地改动数据。

当然那么没有第三个参数的时分cap是怎样处理的呢?当然是适当于cap(old slice) - start了。

这和功能优化有什么关系呢?看个比方:

func noop(s []int) int {
	return s[1] + s[2]
}

func BenchmarkSlice(b *testing.B) {
	slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
	for i := 0; i < b.N; i++ {
		noop(slice[1:5])
	}
}

func BenchmarkSliceWithEqualCap(b *testing.B) {
	slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
	for i := 0; i < b.N; i++ {
		noop(slice[1:5:5])
	}
}

测验成果:

goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
BenchmarkSlice-8                1000000000               0.3263 ns/op          0 B/op          0 allocs/op
BenchmarkSliceWithEqualCap-8    1000000000               0.3015 ns/op          0 B/op          0 allocs/op

假如用benchstat进行比较,均匀来说运用slice[1:5:5]的代码要快3%左右。

事实上这儿有一个go的小优化,当切片表达式里第二个参数和第三个参数相同的时分,cap能够不必额定核算,直接取之前算出来的length就行了。这会少几回内存拜访和一个减法运算。

不信能够看看编译器的代码:

// slice computes the slice v[i:j:k] and returns ptr, len, and cap of result.
// i,j,k may be nil, in which case they are set to their default value.
// v may be a slice, string or pointer to an array.
func (s *state) slice(v, i, j, k *ssa.Value, bounded bool) (p, l, c *ssa.Value) {
	t := v.Type
	var ptr, len, cap *ssa.Value
	switch {
	case t.IsSlice():
		ptr = s.newValue1(ssa.OpSlicePtr, types.NewPtr(t.Elem()), v)
        // 核算slice的len和cap
		len = s.newValue1(ssa.OpSliceLen, types.Types[types.TINT], v)
		cap = s.newValue1(ssa.OpSliceCap, types.Types[types.TINT], v)
	case t.IsString():
		// 省掉,这儿不重要
	case t.IsPtr():
		// 同上省掉
	default:
		s.Fatalf("bad type in slice %v\n", t)
	}

	// 假如是s[:j:k],i会默认设置为0
	if i == nil {
		i = s.constInt(types.Types[types.TINT], 0)
	}
    // 假如是s[i:],则j设置为len(s)
	if j == nil {
		j = len
	}
	three := true
    // 假如是s[i:j:], 则k设置为cap(s)
	if k == nil {
		three = false
		k = cap
	}

	// 对i,j和k进行鸿沟查看

	// 先了解成加减乘除的运算符就行
	subOp := s.ssaOp(ir.OSUB, types.Types[types.TINT])
	mulOp := s.ssaOp(ir.OMUL, types.Types[types.TINT])
	andOp := s.ssaOp(ir.OAND, types.Types[types.TINT])

	// Calculate the length (rlen) and capacity (rcap) of the new slice.
	// For strings the capacity of the result is unimportant. However,
	// we use rcap to test if we've generated a zero-length slice.
	// Use length of strings for that.
	rlen := s.newValue2(subOp, types.Types[types.TINT], j, i)
	rcap := rlen
	if j != k && !t.IsString() {
		rcap = s.newValue2(subOp, types.Types[types.TINT], k, i)
	}

	// 核算slice的内存从那里开端的,在这不重要疏忽

	return rptr, rlen, rcap
}

全体没什么难的,全部切片表达式终究都会走到这个函数,这个函数会出产相应的opcode,这个opcode会过一次相对简略的优化,然后编译器依据这些的opcode生成真实的能够运转的程序。

要点在于if j != k && !t.IsString()这句,分支里那句rcap = s.newValue2(subOp, types.Types[types.TINT], k, i)翻译成一般的go代码的话适当于rcap = k - i,k的值怎样核算的在前面的注释里有写。这意味着切片表达式的二三两个参数假如值相同且不是string,那么会直接复用length而不需求额定的核算了。题外话,这儿尽管我用了“核算”这个词,但实践是rcap和rlen还都仅仅表达式,真实的成果是要在程序运转的时分才干核算得到的,有爱好的话能够自己研究一下go的编译器。

正是由于这个小小的优化带来了纤细的功能进步。

当然,这些仅仅代码生成中的细节,只要这个原因的话我一般不会引荐这样的做法。

所以更重要的是在于前面提到的安全性:约束切片拜访的规模,防止意外地改动数据。在此基础上不只不会有功能下降还有小幅的上升,算是如虎添翼。

适用场景:当切片的cap和length理论上长度应该持平时,最好都清晰地进行设置,比方:slice[i : j+2 : j+2]这样。

上面这个场景估量能占到一半左右,当然还有许多不符合上述要求的场景,所以不要生搬硬套,全部以功能测验为准。

详细能够看这个pr是怎样做的:https://github.com/golang/go/pull/64835

向slice增加多个零值元素的优化

往slice里增加“0”也有些小诀窍,看看下面的测验:

func BenchmarkAppendZeros1(b *testing.B) {
	for i := 0; i < b.N; i++ {
		slice := []int{}
		slice = append(slice, []int{0, 0, 0, 0, 0}...)
	}
}

// 优化版别
func BenchmarkAppendZeros2(b *testing.B) {
	for i := 0; i < b.N; i++ {
		slice := []int{}
		slice = append(slice, make([]int, 5)...)
	}
}

测验成果:

goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
              │   old.txt   │              new.txt               │
              │   sec/op    │   sec/op     vs base               │
AppendZeros-8   31.79n ± 2%   30.04n ± 2%  -5.50% (p=0.000 n=10)

              │  old.txt   │            new.txt             │
              │    B/op    │    B/op     vs base            │
AppendZeros-8   48.00 ± 0%   48.00 ± 0%  ~ (p=1.000 n=10) ¹
¹ all samples are equal

              │  old.txt   │            new.txt             │
              │ allocs/op  │ allocs/op   vs base            │
AppendZeros-8   1.000 ± 0%   1.000 ± 0%  ~ (p=1.000 n=10) ¹
¹ all samples are equal

一行代码,在内存用量没有改动的状况下功能进步了5%。

隐秘仍然在编译器里。

不论是append(s1, s2...)仍是append(s1, make([]T, length)...),编译器都有特其他处理。

前者的流程是这样的:

  1. 创立s2(假如s2是个slice的字面量的话)
  2. 查看s1的cap,不行的状况下要扩容
  3. 将s2的内容copy到s1里

运用make时的流程是这样的:

  1. 查看s1的cap,不行的状况下要扩容
  2. 对length长度的s1的闲暇内存做memclr(将内存中的值全设置为0)

代码在这儿:https://github.com/golang/go/blob/master/src/cmd/compile/internal/walk/assign.go#L647

功能进步的隐秘在于:不必创立暂时的slice,以及memclr做的事比copy更少也更简略所以更快。

并且显着append(s1, make([]T, length)...)的可读性也是更好的,可谓一箭双雕。

适用场景:需求往slice增加接连的零值的时分。

循环打开

用循环处理slice里的数据也是常见的需求,比较下一节会提到的for-range,一般循环拜访数据的方法能够愈加灵敏,并且也不会受1.22改动range运转时行为的影响。

提到循环相关的优化,循环打开是绕不开的论题。望文生义,便是把原本要迭代n次的循环,改成每轮迭代里处理比原先多m倍的数据,这样总的迭代次数会降为n/m + 1次。

这样为啥会更快呢?其间一点是能够少许屡次循环跳转和鸿沟条件的更新及比较。另一点是现代 CPU 都有一个叫做指令流水线的东西,它能够一起运转多条指令,假如它们之间没有数据依靠(后一项数据依靠前一项作为输入)的话,打开循环后意味着有时机让一部分指令并行然后进步吞吐量。

然鹅一般这不是程序员该关怀的事,由于怎样打开循环,什么时分应该打开什么时分不该(循环打开后会影响到当时函数能否被内联等)都是一个有着杰出的优化进程的编译器该做的。

你问go呢?那是天然没有的。在运转时功能和言语表现力之间,go挑选了编译速度。编译得的确快,但是优化上就要眼前一黑了。

所以只能自己写了:

func loop(s []int) int {
	sum := 0
	for i := 0; i < len(s); i++ {
		sum += s[i]
	}
	return sum
}

func unroll4(s []int) int {
	sum := 0
	for i := 0; i < len(s); i += 4 {
		sum += s[i]
		sum += s[i+1]
		sum += s[i+2]
		sum += s[i+3]
	}
	return sum
}

func BenchmarkLoop(b *testing.B) {
	s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 35, 26, 27, 28, 29, 30, 31}
	for i := 0; i < b.N; i++ {
		loop(s)
	}
}

func BenchmarkUnroll4(b *testing.B) {
	s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 35, 26, 27, 28, 29, 30, 31}
	for i := 0; i < b.N; i++ {
		unroll4(s)
	}
}

func BenchmarkUnroll8(b *testing.B) {
	s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 35, 26, 27, 28, 29, 30, 31}
	for i := 0; i < b.N; i++ {
		unroll8(s)
	}
}

测验运用32个int的slice,首要和一个循环里处理四个数据的比照:

goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
         │   old.txt   │               new.txt               │
         │   sec/op    │   sec/op     vs base                │
Unroll-8   9.718n ± 3%   3.196n ± 2%  -67.11% (p=0.000 n=10)

         │  old.txt   │            new.txt             │
         │    B/op    │    B/op     vs base            │
Unroll-8   0.000 ± 0%   0.000 ± 0%  ~ (p=1.000 n=10) ¹
¹ all samples are equal

         │  old.txt   │            new.txt             │
         │ allocs/op  │ allocs/op   vs base            │
Unroll-8   0.000 ± 0%   0.000 ± 0%  ~ (p=1.000 n=10) ¹
¹ all samples are equal

进步了将近67%,适当之大了。然后咱们和一次处理8个数据的比比看:

goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
         │   old.txt   │               new.txt               │
         │   sec/op    │   sec/op     vs base                │
Unroll-8   9.718n ± 3%   2.104n ± 1%  -78.34% (p=0.000 n=10)

这次进步了78%,比较一次只处理四个,处理8个的办法快了30%。

我这为了便利只处理了总数据量是每轮迭代处理数据数量整数倍的状况,非整数倍的时分需求凭借“达夫设备”,在go里完成起来比较费事,所以偷个懒。不过鉴于循环打开带来的进步十分之大,假如确认循环处理slice的代码是功能瓶颈,无妨能够完成一下试试作用。

适用场景:slice的长度需求维持在固定值上,且长度需求时每轮迭代处理数据量的整数倍。

需求细心功能测验的场景:假如单次循环需求处理的内容许多代码很长,那么打开的作用很或许是没有那么好的乃至起反作用,由于过多的代码会影响当时函数和当时代码调用的函数是否被内联以及局部变量的逃逸剖析,前者会使函数调用的开支被扩大一起搅扰分支猜测和流水线履行导致功能下降,后者则会导致不必要的逃逸一起下降功能和增加堆内存用量。

其他每次迭代处理多少个元素也没必要拘泥于4或许2的倍数什么的,理论上不论一次处理几个都会有显着的功能进步,实践测验也是如此,一次性处理3、5或许7个的作用和4或许8个时差不多,整体来说一次处理的越多进步越显着。但假如打开的太过火就会开展成为上面说的需求严厉测验的场景了。所以我主张打开处理的数量最好别超越8个。

防止for-ranges仿制数据带来的损耗

一般的循环结构供给了灵敏的拜访方法,但要是遍历slice的话我想大部分人的首选应该是for-ranges结构吧。

这一节要说的东西与其叫功能优化,到不如说应该是“怎样避开for-ranges”的功能圈套才对。

先说说圈套在哪。

圈套其实有两个,一个基本能避开,另一个得看状况才行。咱们先从能彻底避开的开端。

防止仿制

第一个坑在于range遍历slice的时分,会把待遍历的数据仿制一份到循环变量里,并且从1.22开端range的循环遍历每次迭代都会创立出一个新的实例,假如没留意到这点的话不只功能下降还会使内存压力急剧升高。咱们要做的便是防止不必要的仿制带来的开支。

作为比方,咱们用包括8个int64和1个string的结构体填充slice然后比照仿制和不仿制时的功能:

type Data struct {
	a, b, c, d, e, f, g, h int64
	text                   string
}

func generateData(n int) []Data {
	ret := make([]Data, 0, n)
	for i := range int64(n) {
		ret = append(ret, Data{
			a:    i,
			b:    i + 1,
			c:    i + 2,
			d:    i + 3,
			e:    i + 4,
			f:    i + 5,
			g:    i + 6,
			h:    i + 7,
			text: "测验",
		})
	}
	return ret
}

// 会导致额定仿制数据的比方
func BenchmarkRanges1(b *testing.B) {
	data := generateData(100)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		tmp := int64(0)
		for _, v := range data { // 数据被仿制给循环变量v
			tmp -= v.a - v.h
		}
	}
}

// 防止了仿制的比方
func BenchmarkRanges2(b *testing.B) {
	data := generateData(100)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		tmp := int64(0)
		for i := range data { // 留意这两行
			v := &data[i]
			tmp -= v.a - v.h
		}
	}
}

成果:

goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
         │   old.txt   │              new.txt               │
         │   sec/op    │   sec/op     vs base               │
Ranges-8   33.51n ± 2%   32.63n ± 1%  -2.41% (p=0.000 n=10)

运用指针或许直接经过索引拜访能够防止仿制,如成果所示,结构体越大功能的差异就越显着。此外新版其他go修正了range的语义,从曾经会复用循环变量变成了每轮循环都创立新的循环变量,这会使一部分存在仿制开支的for-range循环变得更慢。

适用场景:需求遍历每个元素,遍历的slice里的单项数据比较大且清晰不需求遍历的数据被额定仿制给循环变量的时分。

遍历字符串的时分防止转化带来的开支

字符串或许有点偏题了,但咱们要说的这点也牵强和slice有关。

这个坑在于,range遍历字符串的时分会把字符串的内容转化成一个个rune,这一步会带来开支,尤其是字符串里只要ascii字符的时分。

写个简略比方看看功能损耗有多少:

func checkByte(s string) bool {
	for _, b := range []byte(s) {
		if b == '\n' {
			return true
		}
	}
	return false
}

func checkRune(s string) bool {
	for _, r := range s {
		if r == '\n' {
			return true
		}
	}
	return false
}

func BenchmarkRanges1(b *testing.B) {
	s := "abcdefghijklmnopqrstuvwxyz1234567890."
	for i := 0; i < b.N; i++ {
		checkRune(s)
	}
}

func BenchmarkRanges2(b *testing.B) {
	s := "abcdefghijklmnopqrstuvwxyz1234567890."
	for i := 0; i < b.N; i++ {
		checkByte(s)
	}
}

这是成果:

goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
         │   old.txt   │               new.txt               │
         │   sec/op    │   sec/op     vs base                │
Ranges-8   36.07n ± 2%   23.95n ± 1%  -33.61% (p=0.000 n=10)

把string转化成[]byte再遍历的功能竟然进步了1/3。换句话说假如你没留意到这个坑,那么就要白白丢掉这么多功能了。

并且将string转化成[]byte是不需求额定分配新的内存的,能够直接复用string内部的数据,当然条件是不会修正转化后的slice,在这儿咱们把这个slice直接交给了range,它不会修正slice,所以转化的开支被省去了。

这个优化是从1.6开端的,有爱好能够看看编译器的代码:https://github.com/golang/go/blob/master/src/cmd/compile/internal/walk/convert.go#L316 (看代码其实还有其他针对这种转化的优化,比方字符串比较短的时分转化出来的[]byte会分配在栈上)

当然,假如你要处理ASCII以外的字符,比方中文汉字,那么这个优化就行不通了。

适用场景:需求遍历处理的字符串里的字符都在ASCII编码的规模内,比方只要换行符英文半角数字和半角标点的字符串。

BCE鸿沟查看消除

鸿沟查看是指在拜访slice元素、运用slice表达式、make创立slice等场景下查看参数的值是否超越最大约束以及是否会越界拜访内存。这些查看是编译器依据编译时取得的信息增加到对应方位上的,查看的代码会在运转时被运转。

这个特性关于程序的安全十分重要。

那么是否只要是有上述表达式的当地就会导致鸿沟查看呢?答案是不,由于鸿沟查看需求取slice的长度或许cap然后进行比较,查看失利的时分会panic,整个形成有些花时间并且对分支猜测不是很友爱,整体上每个拜访slice元素的表达式都增加查看会拖垮功能。

因而鸿沟查看消除就水到渠成呈现了——一些场景下显着index不行能有越界问题,那么查看便是彻底不必要的。

怎样查看编译器在哪里刺进了查看呢?能够用下面这个指令:go build -gcflags='-d=ssa/check_bce' main.go

以上一节的unroll4为比方:

$ go build -gcflags='-d=ssa/check_bce' main.go

# command-line-arguments
./main.go:8:11: Found IsInBounds
./main.go:9:11: Found IsInBounds
./main.go:10:11: Found IsInBounds
./main.go:11:11: Found IsInBounds

现在你会看到两种输出IsInBoundsIsSliceInBounds。两者都是刺进鸿沟检测的证明,查看的内容差不多,只要细小的不同,有爱好能够看ssa怎样生成两者代码的:https://github.com/golang/go/blob/master/src/cmd/compile/internal/ssa/rewriteAMD64.go#L25798

那么这些查看怎样消除呢?详细来说能够分为好几种状况,但随着编译器的开展必定会有不少改动,所以我不准备一一罗列。

已然不罗列,那必定有大致通用的规矩:假如运用index拜访slice前的表达式里能够推算出当时index值不会越界,那么查看就能消除。

举几个比方:

s1 := make([]T, 10)
s1[9] // 常数索引值编译时就能判别是否越界,所以不需求刺进运转时的检测。
_ = s1[i&6]   // 索引的值必定在0-6之间,查看被消除

var s2 []int
_ = s2[:i] // 查看
_ = s2[:i] // 重复拜访,消除鸿沟查看
_ = s2[:i+1] // 查看
_ = s2[:i+1] // 重复的表达式,查看过了所以查看被消除

func f(s []int) int {
    if len(s) < 3 {
        panic("error")
    }

    return s[1] + s[2] // 前面的if确保了这两个拜访必定不会越界,所以查看能够消除
}

// 一种经过暂时变量防止屡次鸿沟检测的常用作法
func f2(s []int) int {
    tmp := s[:4:4] // 这儿会鸿沟查看。这儿还运用了前面说的合理设置slice表达式的cap防止额定开支
    a := tmp[2] // tmp那里的查看确保了这儿不会越界,因而不会再查看
    b := tmp[3] // 同上
    return a+b
}

我没列出全部比方,想看的能够去这儿。

当然有一些躲藏的不能消除查看的场景:

func f(s []int, i int) {
    if i < len(s) {
        fmt.Println(s[i]) // 消除不了,由于i是有符号整数,或许会小于0
    }
}

func f(s []int, i int) {
    if 0 < i && i < len(s) {
        fmt.Println(s[i+2]) // 消除不了,由于i是有符号整数,i+2如果产生溢出,索引值会由于绕回而变成负数
    }
}

有了这些常识,前面的unroll4有四次鸿沟查看,实践上用不着这么多,因而能够改成下面这样:

func unroll4(s []int) int {
	sum := 0
	for i := 0; i < len(s); i += 4 {
		tmp := s[i : i+4 : i+4] // 只要这儿会查看一次
		sum += tmp[0]
		sum += tmp[1]
		sum += tmp[2]
		s

扫描二维码推送至手机访问。

版权声明:本文由51Blog发布,如需转载请注明出处。

本文链接:https://www.51blog.vip/?id=182

分享给朋友:

“golang slice相关常见的功能优化手法” 的相关文章

最简练的Erlang根底

最简练的Erlang根底

0x00 说在前面 Erlang读音/ˈɜːrlæŋ/。第一次见到的时分总感觉怎样读都读不对,后来在维基上看到Erlang标示了音标,才干精确的读出来,并且也没那么奇怪。由于作业才有时机触摸这门言语,也因而只要三天的时刻能够看《Erlang程序设计》这本书。学习这门言语的时分带着一个作业方针:把一个...

c语言强制转换类型, 什么是强制类型转换

在C语言中,强制类型转换是一种将一个表达式的值从一种类型转换为另一种类型的方法。这通常是通过在目标类型名称前加上括号来完成的。强制类型转换的语法如下:```c表达式;```其中,“目标类型”是你希望将表达式转换为的类型,“表达式”是你希望转换的值。这里有一些强制类型转换的例子:1. 将一个整数转换为...

c语言pow,用法、注意事项及实战应用

在C语言中,`pow` 函数用于计算一个数的指定次幂。该函数定义在 `math.h` 头文件中,其原型如下:```cdouble pow;```其中,`x` 是底数,`y` 是指数,函数返回 `x` 的 `y` 次幂的结果。需要注意的是,`pow` 函数返回的是 `double` 类型,即使输入的参...

python机器学习,从基础到实践

python机器学习,从基础到实践

当然可以,机器学习是Python编程中的一个重要领域,它涉及到使用算法从数据中学习,以便做出预测或决策。Python有许多流行的库和框架,如scikitlearn、TensorFlow和PyTorch,可以用于机器学习。如果你对机器学习感兴趣,我可以帮助你学习基础知识,包括数据预处理、特征工程、模型...

python定义一个变量,Python变量定义详解

python定义一个变量,Python变量定义详解

我已经定义了一个名为 `my_variable` 的变量,其值为 42。现在这个变量的值是 42。Python变量定义详解在Python编程语言中,变量是存储数据的基本单元。理解如何定义和使用变量对于编写有效的Python代码至关重要。本文将详细介绍Python中变量的定义方法、规则以及一些实用的技...

php代码混淆, 什么是PHP代码混淆?

php代码混淆, 什么是PHP代码混淆?

PHP代码混淆(Obfuscation)是一种将代码转换为难以阅读和理解的形式的技术,目的是保护代码不被未经授权的人轻易理解和篡改。这通常用于保护软件的知识产权,防止他人窃取或逆向工程。1. 变量和函数重命名:将变量和函数的名称替换为无意义的字符或数字,使代码更难以理解。2. 代码合并:将多个文件合...