IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    Golang 中预分配 slice 内存对性能的影响

    Oilbeater发表于 2024-05-24 09:13:20
    love 0
    • Slice 内存分配理论基础
    • 定量测量
    • Lint 工具 prealloc
    • 总结

    在我代码 review 的过程中经常会关注代码里 slice 的初始化是否分配了预期的内存空间,也就是凡是 var init []int64 的我都会要求尽可能改成 init := make([]int64, 0, length) 格式。但是这个改进对性能究竟有多少影响并没有什么定量的概念,只是教条的去要求。这篇博客会介绍一下预分配内存提升性能的理论基础,定量测量,和自动化检测发现的工具。

    Slice 内存分配理论基础

    Golang Slice 扩容的代码在slice.go 下的 growslice。大体思路是在 Slice 容量小于 256 时
    每次扩容会创建一个容量翻倍的新 slice;当容量大于 256 后,每次扩容会创建一个容量为原先的 1.25 倍的新 slice。之后会将旧 slice 的数据复制到新的 slice,最终返回新的 slice。

    扩容的代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    newcap := oldCap
    doublecap := newcap + newcap
    if newLen > doublecap {
    newcap = newLen
    } else {
    const threshold = 256
    if oldCap < threshold {
    newcap = doublecap
    } else {
    // Check 0 < newcap to detect overflow
    // and prevent an infinite loop.
    for 0 < newcap && newcap < newLen {
    // Transition from growing 2x for small slices
    // to growing 1.25x for large slices. This formula
    // gives a smooth-ish transition between the two.
    newcap += (newcap + 3*threshold) / 4
    }
    // Set newcap to the requested cap when
    // the newcap calculation overflowed.
    if newcap <= 0 {
    newcap = newLen
    }
    }
    }

    因此理论上如果预分配好 slice 的容量,不需要动态扩张我们可以在好几个地方有性能的提升:

    1. 内存只需要一次分配,不需要反复分配。
    2. 不需要反复进行数据复制。
    3. 不需要反复对旧的 slice 进行垃圾回收。
    4. 内存准确分配,不存在动态分配导致的容量浪费。

    理论上来看,预分配 slice 容量相比动态分配会带来性能提升,但具体提升有多少就需要定量测量了。

    定量测量

    我们参考 prealloc 的代码进行简单修改来测量不同容量的 slice 预分配和动态分配对性能的影响。

    测试代码如下,通过修改 length 可以观察不同情况下的性能数据:

    title
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    package prealloc_test

    import "testing"

    var length = 1000

    func BenchmarkNoPreallocate(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
    // Don't preallocate our initial slice
    var init []int64
    for j := 0; j < length; j++ {
    init = append(init, 0)
    }
    }
    }

    func BenchmarkPreallocate(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
    // Preallocate our initial slice
    init := make([]int64, 0, length)
    for j := 0; j < length; j++ {
    init = append(init, 0)
    }
    }
    }

    第一个函数测试动态分配的性能,第二个函数测试预分配的性能。通过下面的命令可以执行测试:

    1
    go test -bench=. -benchmem prealloc_test.go

    在 length = 1 情况下的结果:

    1
    2
    BenchmarkNoPreallocate-12       40228154                27.36 ns/op            8 B/op          1 allocs/op
    BenchmarkPreallocate-12 55662463 19.97 ns/op 8 B/op 1 allocs/op

    在 length 为 1 的情况下,理论上动态分配和静态分配都要进行一次初始化的内存分配,性能不应该有差异,但是实测下来,预分配的耗时为动态分配的 70%,即使在两者内存分配次数一直的情况下,预分配依然有 1.4x 的性能优势。目测性能提升和变量的连续分配相关。

    在 length = 10 情况下的结果:

    1
    2
    BenchmarkNoPreallocate-12        5402014               228.3 ns/op           248 B/op          5 allocs/op
    BenchmarkPreallocate-12 21908133 50.46 ns/op 80 B/op 1 allocs/op

    在 `length`` 为 10 的情况下,预分配依然只进行了一次性能分配,动态分配进行了 5 次性能分配,预分配的性能是动态分配性能的 4 倍。可见即使在 slice 规模较小的时候,预分配依然会有比较明显的性能提升。

    下面是在 length 分别为 129,1025 和 10000 情况下的测试结果:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # length = 129
    BenchmarkNoPreallocate-12 743293 1393 ns/op 4088 B/op 9 allocs/op
    BenchmarkPreallocate-12 3124831 386.1 ns/op 1152 B/op 1 allocs/op

    # length = 1025
    BenchmarkNoPreallocate-12 169700 6571 ns/op 25208 B/op 12 allocs/op
    BenchmarkPreallocate-12 468880 2495 ns/op 9472 B/op 1 allocs/op

    # length = 10000
    BenchmarkNoPreallocate-12 14430 86427 ns/op 357625 B/op 19 allocs/op
    BenchmarkPreallocate-12 56220 20693 ns/op 81920 B/op 1 allocs/op

    在更大容量下,静态分配依然只做一次内存分配,但是性能提升并没有相应成倍增长,整体性能会是动态分配的 2 到 4 倍。应该是在这个过程中有一些其他的消耗,或者 golang 对大容量的复制有特殊的优化,因此性能差距并没有拉大。

    当把 slice 的内容换成更复杂的 struct 时,原以为复制会带来更大的性能开销,但实测复杂 struct 预分配和动态分配的性能差距反而更小,看上去还是有很多内部的优化,表现和直觉并不一致。

    Lint 工具 prealloc

    尽管预分配内存可以带来一定的性能提升,但是在比较大的项目中完全依赖人工去 review 这个问题很容易出现纰漏。这时候就需要用到一些 lint 工具来自动做代码扫描了。prealloc 就是这样一个工具可以扫描潜在的能够预分配但却没有预分配的 slice,并且可以整合到 golangci-lint 中。

    总结

    整体来看 slice 的内存预分配是个比较简单但却能有比较好优化效果的方法,即使在 slice 容量很小的情况下,预分配依然能有比较明显的性能提升。通过 prealloc 这种静态代码扫描工具,可以比较方便的实现这类潜在优化的检测并集成到 CI 中简化日后的操作。



沪ICP备19023445号-2号
友情链接