avatarVincent Blanchon

Summary

The article discusses memory management in Go, focusing on slice manipulation, copying, resetting, and optimization of allocation and copy operations.

Abstract

The article delves into the intricacies of slice memory management in Go, particularly in version 1.13. It explains how slices can be copied using the built-in copy function or the append function, and illustrates methods for deleting elements from a slice. The Go specification ensures that append and copy work correctly even with memory overlaps, relying on the memmove function to handle such cases. The article also covers the optimization techniques introduced in Go 1.5 for resetting slices, which involve using a memory cleaning function to clear slices more efficiently. Furthermore, it highlights a proposed compiler optimization aimed at improving the performance of slice allocation followed by copying, which is not yet part of the standard Go release but promises to enhance the language's efficiency in handling slices.

Opinions

  • The author believes that understanding the internal representation and memory management of slices is crucial for Go developers.
  • The article suggests that the Go language's design, with its built-in functions like copy and append, provides robust mechanisms for slice manipulation.
  • The author appreciates the Go compiler's optimization abilities, particularly in recognizing patterns that can be replaced with more efficient operations, such as the memory cleaning function for resetting slices.
  • The author is anticipatory about the potential performance improvements that would come from the proposed compiler optimization for slice allocation and copying, indicating a positive outlook on the future development of Go.

Go: Slice and Memory Management

Illustration created for “A Journey With Go”, made from the original Go Gopher, created by Renee French.

ℹ️ This article is based on Go 1.13.

The slices in Go are very convenient and quite interesting internally. There are many documentations only about it, including this Go blog, which explains the fundamental concepts of the slices, including its internal representation, very well. This article will focus more on the memory management that surrounds the slices. Let’s start with the manipulation of the elements with their copy and deletion.

Copy

Go allows developers to copy a slice thanks to the built-in function copy. However, the function append that appends values to it could also be used to copy a slice. Here are two examples:

func main() {
   a := []int{4, 2, 1}

   b := make([]int, len(a))
   copy(b, a)

   c := append([]int{}, a...)
}

The newly created slices b and c now an underlying array with the same values. Those two functions can be used in other scenarios, such as the deletion of an element or a part of the slice. Here is an example of removing the second element of the slice thanks to the copy function:

func main() {
   a := []int{4, 2, 1}

   copy(a[1:], a[2:])
   a = a[:len(a)-1]
}

Here is the same behavior with the append function:

func main() {
   a := []int{4, 2, 1}
   a = append(a[:1], a[2:]...)
}

That behavior is actually possible since the Go specification guarantees the result whether there is a memory overlap in the argument or not:

The built-in functions append and copy assist in common slice operations. For both functions, the result is independent of whether the memory referenced by the arguments overlaps.

The generated asm from the first example shows the underlying function that manages the memory copy:

[...]
0x00a3 00163 (main.go:8)    CALL   runtime.memmove(SB)
[...]
0x00f4 00244 (main.go:10)   CALL   runtime.memmove(SB)

This implementation relies on the memmove function that works with overlaps. Here is an example with a slice of byte to illustrate the overlap issue:

func main() {
   a := []byte("hello")
   copy(a[2:], a)
}

Copying the memory forward from the first byte of the source to the last one would overlap with the destination:

Forward copy

memmove solves this issue with the ability to copy backward:

Backward copy

This solves the overlapping issue and satisfies the guarantee offered by the specification.

The implementation in assembly of memmove is actually more complex than that. If it handles forward and backward copy, it does not always need to loop and can handle the copy with very few instruction.

Reset

Reusing a slice means clearing it first. Go provides a compiler optimization in order to clear a slice fast. Here is an example of clearing a slice of integers:

func main() {
   a := []int{4, 2, 1}

   for i := range a {
      a[i] = 0
   }
}

Looping on the slice to clear elements one by one could be cumbersome. To solve that issue, Go 1.5 came with an optimization able to recognize this kind of loop and replace it by a call to a memory cleaning function. This can be confirmed from the assembly code:

0x0047 00071 (main.go:6)    CALL   runtime.memclrNoHeapPointers(SB)

The suffix NoHeapPointers refers to slices not containing any pointers. The same function exists for slices containing pointers; the compiler will call memclrHasPointer in this case.

That optimization speeds up the cleaning significantly. Here is a benchmark with slices of 6, 16, 64 and 256 elements:

https://codereview.appspot.com/137880043

Allocation and copy

When allocating a slice, Go first allocates the memory and zeroes it through the functions memclr* we have seen previously.

However, if the next instruction is a copy of an existing slice, the clear operation would have just wasted time:

For this reason, a CL (#146719) aims to optimize this phase and remove the memory clearing part in case of immediate copy:

This will now speed up the allocation. Here is a benchmark:

func BenchmarkAllocAndCopy(b *testing.B) {
   a := make([]int, 250)
   for k, _ := range a  {
      a[k] = k*2
   }
   b.ResetTimer()

   for i := 0;i < b.N; i++  {
      b := make([]int, len(a))
      copy(b, a)
   }
}

Here is the result of the benchmark:

name               old time/op  new time/op  delta
AllocAndCopy500-8   531ns ± 1%   502ns ± 4%   -5.44%
AllocAndCopy250-8   284ns ± 1%   272ns ± 5%   -4.10%
AllocAndCopy50-8   78.5ns ± 3%  72.1ns ± 1%   -8.16%
AllocAndCopy5-8    30.6ns ± 1%  26.1ns ± 1%  -14.80%

The only condition to trigger this special allocation with copy is to make sure both instructions — make and copy — follow each other.

This compiler optimization is not yet merged and should land in the next versions of Go, making the language smarter when it comes to copying slices.

Golang
Go
Compiler
Recommended from ReadMedium