arraysgoslice

What the efficiency of using "one allocation" realization of 2D slices?


I'm learning the Go and now I've reached the point of studying arrays and slices. I'm referring to Effective Go and A Tour of Go in this question. I have some doubts in the formulation of slice's capacity. See comments in the code below.

package main

import "fmt"

func main() {

    // let's say I need to implement 5x5 picture with 2D slice
    // and I have two ways to do that in acc. with
    // https://go.dev/doc/effective_go#two_dimensional_slices

    // that said:
    // 1. six slices - six underlying arrays
    picture := make([][]uint8, 5)
    for i := range picture {
        picture[i] = make([]uint8, 5)
    }

    // 2. six slices - two* underlying arrays
    // *I THOUGHT the efficiency is hiding in number of arrays
    picture = make([][]uint8, 5)
    pixels := make([]uint8, 25)
    for i := range picture {
        // every iteration pixels[5:] allocates new array...
        picture[i], pixels = pixels[:5], pixels[5:]
        // ...but in the new iteration it's deleted
        // leaving slice of initial pixels array in picture[i]...
    }
    // ...and here we have only two arrays...
    // ...         ^^^ B5t ^^^

    // there are six arrays anyway
    // but their capacity is 5 for picture and
    // 25, 20...5 respectively for its contents
    fmt.Print(cap(picture), " ")
    for i := range picture {
        fmt.Print(cap(picture[i]), " ")
    }
    fmt.Println()
    // if you don't understand let me explain my position
    // capacity is characteristic of underlying array rather than
    // of slice itself

    // in acc. with https://go.dev/tour/moretypes/11
    // "The capacity of a slice is the number of
    // elements in the underlying array..."

    // I believe that capacity of array is immutable one and
    // one array cannot have different cap's at the same time

    picture = make([][]uint8, 5)
    for i := range picture {
        picture[i] = make([]uint8, 5)
    }

    // here we have six arrays but capacity of each is 5
    // so the first solution works efficiently than second one
    //                  I believe
    fmt.Print(cap(picture), " ")
    for i := range picture {
        fmt.Print(cap(picture[i]), " ")
    }
    fmt.Println()

}

The output of this code is:

5 25 20 15 10 5 
5 5 5 5 5 5 

Can someone explain me where the efficiency of using one allocation method is hiding?


Solution

  • The second Effective Go Two-dimensional slices example was created before Full slice expressions were added to The Go Programming Language Specification.

    one allocation, sliced into lines:
    
    // Allocate the top-level slice, the same as before.
    picture := make([][]uint8, YSize) // One row per unit of y.
    // Allocate one large slice to hold all the pixels.
    pixels := make([]uint8, XSize*YSize) // Has type []uint8 even though picture is [][]uint8.
    // Loop over the rows, slicing each row from the front of the remaining pixels slice.
    for i := range picture {
        picture[i], pixels = pixels[:XSize], pixels[XSize:]
    

    The second example should read:

    one allocation, sliced into lines:
    
    // Allocate the top-level slice, the same as before.
    picture := make([][]uint8, YSize) // One row per unit of y.
    // Allocate one large slice to hold all the pixels.
    pixels := make([]uint8, XSize*YSize) // Has type []uint8 even though picture is [][]uint8.
    // Loop over the rows, slicing each row from the front of the remaining pixels slice.
    for i := range picture {
        // Use a full slice expression for the correct row capacity
        picture[i], pixels = pixels[:XSize:XSize], pixels[XSize:]
    }
    

    package main
    
    import (
        "fmt"
    )
    
    func main() {
        XSize, YSize := 5, 5
    
        // Effective Go: Two-dimensional slices
        // https://go.dev/doc/effective_go#two_dimensional_slices
        // one allocation, sliced into lines:
        // Allocate the top-level slice, the same as before.
        picture := make([][]uint8, YSize) // One row per unit of y.
        // Allocate one large slice to hold all the pixels.
        pixels := make([]uint8, XSize*YSize) // Has type []uint8 even though picture is [][]uint8.
        // Loop over the rows, slicing each row from the front of the remaining pixels slice.
        for i := range picture {
            // Use a full slice expression for the correct row capacity
            // https://go.dev/ref/spec#Slice_expressions
            picture[i], pixels = pixels[:XSize:XSize], pixels[XSize:]
        }
    
        fmt.Print(cap(picture), " ")
        for i := range picture {
            fmt.Print(cap(picture[i]), " ")
        }
        fmt.Println()
    }
    

    https://go.dev/play/p/7I2ZL5Jj8Lw.

    Output:

    5 5 5 5 5 5
    

    A simple Go benchmark illustrates some of the effects of minimizing allocations.

    Benchmark1stExample-16  6710131  175.6 ns/op  154 B/op  6 allocs/op
    Benchmark2ndExample-16  9674449  121.3 ns/op  160 B/op  2 allocs/op