【问题标题】:What is the point in setting a slice's capacity?设置切片容量有什么意义?
【发布时间】:2018-01-07 11:38:30
【问题描述】:

在 Golang 中,我们可以使用内置的 make() 函数来创建具有给定初始长度和容量的切片。

考虑以下几行,切片的长度设置为 1,容量设置为 3:

func main() {
    var slice = make([]int, 1, 3)
    slice[0] = 1
    slice = append(slice, 6, 0, 2, 4, 3, 1)
    fmt.Println(slice)
}

看到这个程序打印出来我很惊讶:

[1 6 0 2 4 3 1]

这让我想知道 - 如果 append() 可以简单地超过它,那么最初定义切片的容量有什么意义?设置足够大的容量是否有性能提升?

【问题讨论】:

    标签: go slice


    【解决方案1】:

    切片实际上只是一种管理底层数组的奇特方式。它会自动跟踪大小,并根据需要重新分配新空间。

    当您附加到切片时,运行时每次超过其当前容量时都会将其容量加倍。它必须复制所有元素才能做到这一点。如果您在开始之前就知道它有多大,那么您可以通过预先抓取它来避免一些复制操作和内存分配。

    当您make 切片提供容量时,您设置的是初始容量,而不是任何类型的限制

    请参阅this blog post on slices 了解切片的一些有趣的内部细节。

    【讨论】:

    • 在某个点,它停止加倍并开始以 25% 的增量增加。我认为这发生在 1024 个元素之后。
    • 不只是复制操作需要时间,还有分配。
    • 正如斯蒂芬所说,这并不像加倍那么简单,但这里有一个有趣的案例研究。我最近运行了一些基准测试(1.15.x)。零切片是绝对最坏的情况。在达到cap: 10 之前,它只添加一个元素,然后开始加倍(直到 1000 左右)。所以如果你从一个 nil 切片开始,或者用 0 cap 做,然后添加 10 个元素,它必须分配和复制每次迭代。我个人鼓励总是设置上限。您通常可以从另一个值得出确切的 n,或者至少可以将其最初设置为 10 以避免前 10 次分配/复制。基准很糟糕。
    【解决方案2】:

    slice 是一个简单的array 的精彩抽象。您可以获得各种不错的功能,但其核心是array。 (出于某种原因,我以相反的顺序解释以下内容)。因此,如果/当您指定3capacity 时,在内存中分配一个长度为3 的数组,您可以在不需要重新分配内存的情况下达到append。此属性在make 命令中是可选的,但请注意slice 将始终具有capacity,无论您是否选择指定一个。如果您指定 length(它也始终存在),则 slice 可以索引到该长度。 capacity 的其余部分隐藏在幕后,因此在使用 append 时不必分配全新的数组

    这是一个更好地解释机制的例子。

    s := make([]int, 1, 3)

    底层array将分配int的零值3(即0):

    [0,0,0]

    但是,length 设置为1,因此切片本身只会打印[0],如果您尝试索引第二个或第三个值,它将panic,如slice的机制不允许。如果你s = append(s, 1) 到它,你会发现它实际上被创建为包含zero 值直到length,你最终会得到[0,1]。此时,您可以在整个底层array 被填充之前再次append,另一个append 将强制它分配一个新的并以双倍的容量复制所有值。这实际上是一个相当昂贵的操作。

    因此对您的问题的简短回答是,预分配 capacity 可用于极大地提高代码效率。特别是如果slice 要么会变得非常大,要么包含复杂的structs(或两者兼有),因为structzero 值实际上是每个zero 值它的fields。这并不是因为它会避免分配这些值,无论如何它都必须这样做,而是因为 append 在每次需要调整底层数组的大小时都必须重新分配充满这些零值的新 arrays。

    短操场示例:https://play.golang.org/p/LGAYVlw-jr

    【讨论】:

      【解决方案3】:

      正如其他人已经说过的,使用cap 参数可以避免不必要的分配。为了了解性能差异,假设您有一个随机值 []float64,并且想要一个新切片来过滤不高于 0.5 的值。

      简单的方法 - 没有 len 或 cap 参数

      func filter(input []float64) []float64 {
          ret := make([]float64, 0)
          for _, el := range input {
              if el > .5 {
                  ret = append(ret, el)
              }
          }
          return ret
      }
      

      更好的方法 - 使用上限参数

      func filterCap(input []float64) []float64 {
          ret := make([]float64, 0, len(input))
          for _, el := range input {
              if el > .5 {
                  ret = append(ret, el)
              }
          }
          return ret
      }
      

      基准(n=10)

      filter     131 ns/op    56 B/op  3 allocs/op
      filterCap   56 ns/op    80 B/op  1 allocs/op
      

      使用 cap 使程序速度提高了 2 倍以上,并将分配数量从 3 减少到 1。现在大规模发生了什么?

      基准(n=1,000,000)

      filter     9630341 ns/op    23004421 B/op    37 allocs/op
      filterCap  6906778 ns/op     8003584 B/op     1 allocs/op
      

      由于对 runtime.makeslice 的调用减少了 36 次,因此速度差异仍然很大(~1.4 倍)。但是,更大的区别是内存分配(约少 4 倍)。

      更好 - 校准上限

      您可能已经在第一个基准测试中注意到cap 使整体内存分配变差(80B vs 56B)。这是因为您分配了 10 个插槽,但平均只需要 5 个。这就是您不想将cap 设置得过高的原因。鉴于您对程序的了解,您可能能够校准容量。在这种情况下,我们可以估计过滤后的切片需要的槽数是原始切片的 50%。

      func filterCalibratedCap(input []float64) []float64 {
          ret := make([]float64, 0, len(input)/2)
          for _, el := range input {
              if el > .5 {
                  ret = append(ret, el)
              }
          }
          return ret
      }
      

      不出所料,这个经过校准的 cap 分配的内存是其前身的 50%,因此对于 1m 个元素的简单实现来说,这大约是 8 倍的改进。

      另一种选择 - 使用直接访问而不是附加

      如果您希望在这样的程序中节省更多时间,请使用 len 参数进行初始化(并忽略 cap 参数),直接访问新切片而不是使用 append,然后丢弃您的所有插槽不需要。

      func filterLen(input []float64) []float64 {
          ret := make([]float64, len(input))
          var counter int
          for _, el := range input {
              if el > .5 {
                  ret[counter] = el
                  counter++
              }
          }
          return ret[:counter]
      }
      

      这比 filterCap 在规模上快约 10%。但是,除了更复杂之外,如果您尝试校准内存要求,此模式不会提供与 cap 相同的安全性。

      • 使用cap 校准,如果您低估了所需的总容量,则程序会在需要时自动分配更多。
      • 使用这种方法,如果您低估了所需的len 总数,程序将失败。在这个例子中,如果你初始化为ret := make([]float64, len(input)/2),结果是len(output) > len(input)/2,那么在某些时候程序会尝试访问一个不存在的插槽并出现恐慌。

      【讨论】:

      • 我真的很喜欢你在这里的回答。我很好奇您在这些基准测试中使用了哪个版本的 go。我最近发现n=10 是一个非常邪恶的最坏情况(当cap 从0 开始时),但它并没有在您的基准测试中显示。我发现cap < 10 的切片仅在调用makeslice 时添加一个元素。但是您的基准测试只显示了 3 个带有 n=10 的分配。我将不得不再次阅读更改日志。
      【解决方案4】:

      每次将项目添加到具有len(mySlice) == cap(mySlice) 的切片时,底层数据结构都会替换为更大的结构。

      fmt.Printf("Original Capacity: %v", cap(mySlice))  // Output: 8
      mySlice = append(mySlice, myNewItem)
      fmt.Printf("New Capacity: %v", cap(mySlice))  // Output: 16
      

      在这里,mySlice 被替换为(通过赋值运算符)一个新切片,其中包含原始 mySlice 的所有元素,加上 myNewItem,再加上一些空间(容量)可以在不触发此调整大小的情况下增长。

      您可以想象,这种调整大小的操作在计算上并不简单。

      通常情况下,所有调整大小的操作都可以避免如果您知道需要在mySlice 中存储多少项目。如果你有这方面的预知,你可以预先设置原始 slice 的容量,避免所有的 resize 操作。

      (实际上,通常可以知道有多少项目将添加到集合中;尤其是在将数据从一种格式转换为另一种格式时。)

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 2015-10-19
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2016-01-02
        • 2012-08-14
        • 2011-11-10
        • 1970-01-01
        相关资源
        最近更新 更多